Learn some
Build an issue tracker with distributed state.
About this guide
You might notice that right now, this guide is the only form of documentation there is for Jazz. Over time, we're hoping to introduce independent doc sections for every concept in Jazz, but right now this works as:
- a quickstart guide
- a reference for the concepts in Jazz (ordered from simple & most important to more advanced)
- a tutorial that makes you build a full app (that you can use as a base)
Plus, if you get stuck or you have questions, ask us on Discord and we'll know exactly where you're at.
Project Setup
-
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.)
-
Install
(in a new terminal window):jazz-tools
andjazz-react
cd circular npm install jazz-tools jazz-react
-
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://mesh.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 userme
, 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 itsID
.- So we'll slightly adapt our
useState
to only keep track of an issue ID... - ...and then use
useCoState
to get the actual issue
- So we'll slightly adapt our
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 loose track of their ID after a reload.
So let's store the ID in window URL state 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 Project
s instead of Issue
s. (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 Issue
s.
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 theowner
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 nestedListOfIssues
and eachIssue
will be automatically loaded and subscribed to when we access them. - However, because either the
Project
,ListOfIssues
, or eachIssue
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 eachIssue
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. 🚧