React guide

This is a step-by-step tutorial where we'll build an issue tracker app using React.

You'll learn how to set up a Jazz app, use Jazz Cloud for sync and storage, create and manipulate data using Collaborative Values (CoValues), build a UI and subscribe to changes, set permissions, and send invites.

Project setup

  1. Create a project called "circular" from a generic Vite starter template:
npx degit gardencmp/vite-ts-react-tailwind circular cd circular npm install npm run dev

You should now have an empty app running, typically at localhost:5173.

(If you make changes to the code, the app will automatically refresh.)

  1. Install jazz-tools and jazz-react

    (in a new terminal window):
cd circular npm install jazz-tools jazz-react
  1. Modify src/main.tsx to set up a Jazz context:
import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import "./index.css"; import { createJazzReactApp, useDemoAuth, DemoAuthBasicUI, } from "jazz-react"; const Jazz = createJazzReactApp(); export const { useAccount, useCoState } = Jazz; function JazzAndAuth({ children }: { children: React.ReactNode }) { const [auth, authState] = useDemoAuth(); return ( <> <Jazz.Provider auth={auth} // replace `you@example.com` with your email as a temporary API key peer="wss://cloud.jazz.tools/?key=you@example.com" > {children} </Jazz.Provider> <DemoAuthBasicUI appName="Circular" state={authState} /> </> ); } ReactDOM.createRoot(document.getElementById("root")!).render( <React.StrictMode> <JazzAndAuth> <App /> </JazzAndAuth> </React.StrictMode> );

This sets Jazz up, extracts app-specific hooks for later, and wraps our app in the provider.

Intro to CoValues

Let's learn about the central idea behind Jazz: Collaborative Values.

What if we could treat distributed state like local state? That's what CoValues do.

We can

  • create CoValues, anywhere
  • load CoValues by ID, from anywhere else
  • edit CoValues, from anywhere, by mutating them like local state
  • subscribe to edits in CoValues, whether they're local or remote

Declaring our own CoValues

To make our own CoValues, we first need to declare a schema for them. Think of a schema as a combination of TypeScript types and runtime type information.

Let's start by defining a schema for our most central entity in Circular: an Issue.

Create a new file src/schema.ts and add the following:

import { CoMap, co } from "jazz-tools"; export class Issue extends CoMap { title = co.string; description = co.string; estimate = co.number; status? = co.literal("backlog", "in progress", "done"); }

Reading from CoValues

CoValues are designed to be read like simple local JSON state. Let's see how we can read from an Issue by building a component to render one.

Create a new file src/components/Issue.tsx and add the following:

import { Issue } from "../schema"; export function IssueComponent({ issue }: { issue: Issue }) { return ( <div className="grid grid-cols-6 text-sm border-r border-b [&>*]:p-2 [&>*]:border-l [&>*]:border-t"> <h2>{issue.title}</h2> <p className="col-span-3">{issue.description}</p> <p>Estimate: {issue.estimate}</p> <p>Status: {issue.status}</p> </div> ); }

Simple enough!

Creating CoValues

To actually see an Issue, we have to create one. This is where things start to get interesting...

Let's modify src/App.tsx to prepare for creating an Issue and then rendering it:

import { useState } from "react"; import { Issue } from "./schema"; import { IssueComponent } from "./components/Issue.tsx"; function App() { const [issue, setIssue] = useState<Issue>(); if (issue) { return <IssueComponent issue={issue} />; } else { return <button>Create Issue</button>; } } export default App;

Now, finally, let's implement creating an issue:

import { useState } from "react"; import { Issue } from "./schema"; import { IssueComponent } from "./components/Issue.tsx"; import { useAccount } from "./main"; function App() { const { me } = useAccount(); const [issue, setIssue] = useState<Issue>(); const createIssue = () => { const newIssue = Issue.create( { title: "Buy terrarium", description: "Make sure it's big enough for 10 snails.", estimate: 5, status: "backlog", }, { owner: me }, ); setIssue(newIssue); }; if (issue) { return <IssueComponent issue={issue} />; } else { return <button onClick={createIssue}>Create Issue</button>; } } export default App;

🏁 Now you should be able to create a new issue by clicking the button and then see it rendered!

Preview

Buy terrarium

Make sure it's big enough for 10 snails.

Estimate: 5

Status: backlog

We'll already notice one interesting thing here:

  • We have to create every CoValue with an owner!
    • this will determine access rights on the CoValue, which we'll learn about in "Groups & Permissions"
    • here we set owner to the current user me, which we get from the Jazz context / useAccount

Behind the scenes, Jazz not only creates the Issue in memory but also automatically syncs an encrypted version to the cloud and persists it locally. The Issue also has a globally unique ID.

We'll make use of both of these facts in a bit, but for now let's start with local editing and subscribing.

Editing CoValues and subscribing to edits

Since we're the owner of the CoValue, we should be able to edit it, right?

And since this is a React app, it would be nice to subscribe to edits of the CoValue and reactively re-render the UI, like we can with local state.

This is exactly what the useCoState hook is for!

  • Note that useCoState doesn't take a CoValue directly, but rather a CoValue's schema, plus its ID.
    • So we'll slightly adapt our useState to only keep track of an issue ID...
    • ...and then use useCoState to get the actual issue

Let's modify src/App.tsx:

import { useState } from "react"; import { Issue } from "./schema"; import { IssueComponent } from "./components/Issue.tsx"; import { useAccount, useCoState } from "./main"; import { ID } from "jazz-tools" function App() { const { me } = useAccount(); const [issueID, setIssueID] = useState<ID<Issue>>(); const issue = useCoState(Issue, issueID); const createIssue = () => { const newIssue = Issue.create( { title: "Buy terrarium", description: "Make sure it's big enough for 10 snails.", estimate: 5, status: "backlog", }, { owner: me }, ); setIssueID(newIssue.id); }; if (issue) { return <IssueComponent issue={issue} />; } else { return <button onClick={createIssue}>Create Issue</button>; } } export default App;

And now for the exciting part! Let's make src/components/Issue.tsx an editing component.

import { Issue } from "../schema"; export function IssueComponent({ issue }: { issue: Issue }) { return ( <div className="grid grid-cols-6 text-sm border-r border-b [&>*]:p-2 [&>*]:border-l [&>*]:border-t"> <input type="text" value={issue.title} onChange={(event) => { issue.title = event.target.value }}/> <textarea className="col-span-3" value={issue.description} onChange={(event) => { issue.description = event.target.value }}/> <label className="flex"> Estimate: <input type="number" className="text-right min-w-0" value={issue.estimate} onChange={(event) => { issue.estimate = Number(event.target.value) }}/> </label> <select value={issue.status} onChange={(event) => { issue.status = event.target.value as "backlog" | "in progress" | "done" }} > <option value="backlog">Backlog</option> <option value="in progress">In Progress</option> <option value="done">Done</option> </select> </div> ); }

Preview

🏁 Now you should be able to edit the issue after creating it!

You'll immediately notice that we're doing something non-idiomatic for React: we mutate the issue directly, by assigning to its properties.

This works because CoValues

  • intercept these edits
  • update their local view accordingly (React doesn't really care after rendering)
  • notify subscribers of the change (who will receive a fresh, updated view of the CoValue)

We have one subscriber on our Issue, with useCoState in src/App.tsx, which will cause the App component and its children to re-render whenever the Issue changes.

Automatic local & cloud persistence

So far our Issue CoValues just looked like ephemeral local state. We'll now start exploring the first main feature that makes CoValues special: automatic persistence.

Actually, all the Issue CoValues we've created so far have already been automatically persisted to the cloud and locally - but we lose track of their ID after a reload.

So let's store the ID in the browser's URL and make sure our useState is in sync with that.

import { useState } from "react"; import { Issue } from "./schema"; import { IssueComponent } from "./components/Issue.tsx"; import { useAccount, useCoState } from "./main"; import { ID } from "jazz-tools" function App() { const { me } = useAccount(); const [issueID, setIssueID] = useState<ID<Issue> | undefined>( (window.location.search?.replace("?issue=", "") || undefined) as ID<Issue> | undefined, ); const issue = useCoState(Issue, issueID); const createIssue = () => { const newIssue = Issue.create( { title: "Buy terrarium", description: "Make sure it's big enough for 10 snails.", estimate: 5, status: "backlog", }, { owner: me }, ); setIssueID(newIssue.id); window.history.pushState({}, "", `?issue=${newIssue.id}`); }; if (issue) { return <IssueComponent issue={issue} />; } else { return <button onClick={createIssue}>Create Issue</button>; } } export default App;

🏁 Now you should be able to create an issue, edit it, reload the page, and still see the same issue.

Remote sync

To see that sync is also already working, try the following:

  • copy the URL to a new tab in the same browser window and see the same issue
  • edit the issue and see the changes reflected in the other tab!

This works because we load the issue as the same account that created it and owns it (remember how you set { owner: me }).

But how can we share an Issue with someone else?

Simple public sharing

We'll learn more about access control in "Groups & Permissions", but for now let's build a super simple way of sharing an Issue by just making it publicly readable & writable.

All we have to do is create a new group to own each new issue and add "everyone" as a "writer":

import { useState } from "react"; import { Issue } from "./schema"; import { IssueComponent } from "./components/Issue.tsx"; import { useAccount, useCoState } from "./main"; import { ID, Group } from "jazz-tools" function App() { const { me } = useAccount(); const [issueID, setIssueID] = useState<ID<Issue> | undefined>( (window.location.search?.replace("?issue=", "") || undefined) as ID<Issue> | undefined, ); const issue = useCoState(Issue, issueID); const createIssue = () => { const group = Group.create({ owner: me }); group.addMember("everyone", "writer"); const newIssue = Issue.create( { title: "Buy terrarium", description: "Make sure it's big enough for 10 snails.", estimate: 5, status: "backlog", }, { owner: group }, ); setIssueID(newIssue.id); window.history.pushState({}, "", `?issue=${newIssue.id}`); }; if (issue) { return <IssueComponent issue={issue} />; } else { return <button onClick={createIssue}>Create Issue</button>; } } export default App;

🏁 Now you should be able to open the Issue (with its unique URL) on another device or browser, or send it to a friend and you should be able to edit it together in realtime!

This concludes our intro to the essence of CoValues. Hopefully you're starting to have a feeling for how CoValues behave and how they're magically available everywhere.

Refs & auto-subscribe

Now let's have a look at how to compose CoValues into more complex structures and build a whole app around them.

Let's extend our two data model to include "Projects" which have a list of tasks and some properties of their own.

Using plain objects, you would probably type a Project like this:

type Project = { name: string; issues: Issue[]; };

In order to create this more complex structure in a fully collaborative way, we're going to need references that allow us to nest or link CoValues.

Add the following to src/schema.ts:

import { CoMap, CoList, co } from "jazz-tools"; export class Issue extends CoMap { title = co.string; description = co.string; estimate = co.number; status? = co.literal("backlog", "in progress", "done"); } export class ListOfIssues extends CoList.Of(co.ref(Issue)) {} export class Project extends CoMap { name = co.string; issues = co.ref(ListOfIssues); }

Now let's change things up a bit in terms of components as well.

First, we'll change App.tsx to create and render Projects instead of Issues. (We'll move the useCoState into the ProjectComponent we'll create in a second).

import { useState } from "react"; import { Project, ListOfIssues } from "./schema"; import { ProjectComponent } from "./components/Project.tsx"; import { useAccount } from "./main"; import { ID, Group } from "jazz-tools" function App() { const { me } = useAccount(); const [projectID, setProjectID] = useState<ID<Project> | undefined>( (window.location.search?.replace("?project=", "") || undefined) as ID<Project> | undefined ); const issue = useCoState(Issue, issueID); const createProject = () => { const group = Group.create({ owner: me }); group.addMember("everyone", "writer"); const newProject = Project.create( { name: "New Project", issues: ListOfIssues.create([], { owner: group }) }, { owner: group }, ); setProjectID(newProject.id); window.history.pushState({}, "", `?project=${newProject.id}`); }; if (projectID) { return <ProjectComponent projectID={projectID} />; } else { return <button onClick={createProject}>Create Project</button>; } } export default App;

Now we'll actually create the ProjectComponent that renders a Project and its Issues.

Create a new file src/components/Project.tsx and add the following:

import { ID } from "jazz-tools"; import { Project, Issue } from "../schema"; import { IssueComponent } from "./Issue.tsx"; import { useCoState } from "../main"; export function ProjectComponent({ projectID }: { projectID: ID<Project> }) { const project = useCoState(Project, projectID); const createAndAddIssue = () => { project?.issues?.push(Issue.create({ title: "", description: "", estimate: 0, status: "backlog", }, { owner: project._owner })); }; return project ? ( <div> <h1>{project.name}</h1> <div className="border-r border-b"> {project.issues?.map((issue) => ( issue && <IssueComponent key={issue.id} issue={issue} /> ))} <button onClick={createAndAddIssue}>Create Issue</button> </div> </div> ) : ( <div>Loading project...</div> ); }

🏁 Now you should be able to create a project, add issues to it, share it, and edit it collaboratively!

Two things to note here:

  • We create a new Issue like before, and then push it into the issues list of the Project. By setting the owner to the Project's owner, we ensure that the Issue has the same access rights as the project itself.
  • We only need to use useCoState on the Project, and the nested ListOfIssues and each Issue will be automatically loaded and subscribed to when we access them.
  • However, because either the Project, ListOfIssues, or each Issue might not be loaded yet, we have to check for them being defined.

Precise loading depths

The load-and-subscribe-on-access is a convenient way to have your rendering drive data loading (including in nested components!) and lets you quickly chuck UIs together without worrying too much about the shape of all data you'll need.

But you can also take more precise control over loading by defining a minimum-depth to load in useCoState:

import { ID } from "jazz-tools"; import { Project, Issue } from "../schema"; import { IssueComponent } from "./Issue.tsx"; import { useCoState } from "../main"; export function ProjectComponent({ projectID }: { projectID: ID<Project> }) { const project = useCoState(Project, projectID, { issues: [{}] }); const createAndAddIssue = () => { project?.issues.push(Issue.create({ title: "", description: "", estimate: 0, status: "backlog", }, { owner: project._owner })); }; return project ? ( <div> <h1>{project.name}</h1> <div className="border-r border-b"> {project.issues.map((issue) => ( <IssueComponent key={issue.id} issue={issue} /> ))} <button onClick={createAndAddIssue}>Create Issue</button> </div> </div> ) : ( <div>Loading project...</div> ); }

The loading-depth spec { issues: [{}] } means "in Project, load issues and load each item in issues shallowly". (Since an Issue doesn't have any further references, "shallowly" actually means all its properties will be available).

  • Now, we can get rid of a lot of coniditional accesses because we know that once project is loaded, project.issues and each Issue in it will be loaded as well.
  • This also results in only one rerender and visual update when everything is loaded, which is faster (especially for long lists) and gives you more control over the loading UX.

Groups & permissions

We've seen briefly how we can use Groups to give everyone access to a Project, and how we can use { owner: me } to make something private to the current user.

Groups / Accounts as permission scopes

This gives us a hint of how permissions work in Jazz: every CoValue has an owner, and the access rights on that CoValue are determined by its owner.

  • If the owner is an Account, only that Account can read and write the CoValue.
  • If the owner is a Group, the access rights depend on the role of the Account (that is trying to access the CoValue) in that Group.
    • "reader"s can read but not write to CoValues belonging to the Group.
    • "writer"s can read and write to CoValues belonging to the Group.
    • "admin"s can read and write to CoValues belonging to the Group and can add and remove other members from the Group itself.

Creating invites

There is also an abstraction for creating invitations to join a Group (with a specific role) that you can use to add people without having to know their Account ID.

Let's use these abstractions to build teams for a Project that we can invite people to.

Turns out, we're already mostly there! First, let's remove making the Project public:

import { useState } from "react"; import { Project, ListOfIssues } from "./schema"; import { ProjectComponent } from "./components/Project.tsx"; import { useAccount } from "./main"; import { ID, Group } from "jazz-tools" function App() { const { me } = useAccount(); const [projectID, setProjectID] = useState<ID<Project> | undefined>( (window.location.search?.replace("?project=", "") || undefined) as ID<Project> | undefined, ); const createProject = () => { const group = Group.create({ owner: me }); group.addMember("everyone", "writer"); const newProject = Project.create( { name: "New Project", issues: ListOfIssues.create([], { owner: group }) }, { owner: group }, ); setProjectID(newProject.id); window.history.pushState({}, "", `?project=${newProject.id}`); }; if (projectID) { return <ProjectComponent projectID={projectID} />; } else { return <button onClick={createProject}>Create Project</button>; } } export default App;

Now, inside ProjectComponent, let's add a button to invite guests (read-only) or members (read-write) to the Project.

import { ID } from "jazz-tools"; import { Project, Issue } from "../schema"; import { IssueComponent } from "./Issue.tsx"; import { useCoState } from "../main"; import { createInviteLink } from "jazz-react"; export function ProjectComponent({ projectID }: { projectID: ID<Project> }) { const project = useCoState(Project, projectID, { issues: [{}] }); const invite = (role: "reader" | "writer") => { const link = createInviteLink(project, role, { valueHint: "project" }); navigator.clipboard.writeText(link); }; const createAndAddIssue = () => { project?.issues.push(Issue.create({ title: "", description: "", estimate: 0, status: "backlog", }, { owner: project._owner })); }; return project ? ( <div> <h1>{project.name}</h1> {project._owner?.myRole() === "admin" && ( <> <button onClick={() => invite("reader")}>Invite Guest</button> <button onClick={() => invite("writer")}>Invite Member</button> </> )} <div className="border-r border-b"> {project.issues.map((issue) => ( <IssueComponent key={issue.id} issue={issue} /> ))} <button onClick={createAndAddIssue}>Create Issue</button> </div> </div> ) : ( <div>Loading project...</div> ); }

Consuming invites

🚧 OH NO - This is as far as we've written the Guide. 🚧

-> Complain on GitHub