Building a Collaborative Todo App w/ live cursors using GraphQL subscriptions
Andrej Tlฤina
Posted on September 3, 2022
Hello! I started a new project! I initially wanted to do a chat app, but then I realized, I would be just following tutorials, so I decided to build a collaborative real-time todo app, where each user can see the cursors of other users. My immediate thinking went to socket.io, but then I found video by Jack Herrington. In this video, Jack used GraphQL subscriptions, which are (by my super scientific explanation) GraphQL WebSockets. When a subscription is defined on the server and the client subscribes to it, the user can get new data via a WebSocket connection.
Once I did a little research, I realized I could build a real-time app with the help of GraphQl. Two things (real-time and GraphQL) I always wanted to try.
So, here's a little preview of what I've built and a bit of explanation of how I did it ๐
By the way, I won't be talking about things like "what is GraphQL", because there's a lot of better sources for that. Also, no styles or react components, because I'm hoping you'll create your own components and styles.
Set-up
For this project, I needed a server and client. In the empty directory, I created a server folder. In the server folder I created src/index.tsx
, then ran npm init -y
and downloaded a few things
yarn add -D @types/node @types/uuid nodemon ts-node typescript
yarn add graphql-yoga uuid
nodemon is there for running the server, for that I also had to create a new script "dev": "nodemon" and
nodemon.json
next to package.json.nodemon.json
holds configuration mine can be found hereFor this project I used MongoDB. You can get free DB following this link. To work with DB, I love to use prisma. You can set up prisma following this link
For client set-up we can use Vite by running
yarn create vite client --template react-ts
I also added
tailwind
via this guide.
We can add really necessary packages on client via
yarn add @apollo/client graphql subscriptions-transport-ws throttle-typescript
In the previous paragraph, I wrote really necessary packages, because I used packages like
@react/dialog
, which are cool but not necessary, and you don't even need tailwind, if you like other full-fledged UI libraries, for instance, Chakra UI.
Initialize Server-Client connection
Obviously, we want the server and client to "talk" to each other. For that, we have to initialize the server and then connect the client to it.
In server/index.tsx
import { GraphQLServer, PubSub } from "graphql-yoga";
import { PrismaClient } from "@prisma/client";
const pubsub = new PubSub();
const prisma = new PrismaClient();
export interface Context {
pubsub: PubSub;
prisma: PrismaClient;
}
const typeDefs = `
type Query {
hello: String!
}
`
const resolvers = {
Query: {
hello: () => "world",
},
}
const server = new GraphQLServer({
typeDefs,
resolvers,
context: { pubsub, prisma } as Context,
});
server.start(({ port }) => {
console.log(`Server on http://localhost:${port}/`);
});
After running yarn dev
we should get a log with the following:
Server on http://localhost:4000/
That means server is running, and after visiting http://localhost:4000/
, there's a GraphiQL interface, where we can test our queries and whatnot.
To connect client to server I like to create a file app/App.tsx
, where I'll have something like
import React from "react";
import WithApollo from "./withApollo";
function App() {
return (
<WithApollo>
// other providers and components
</WithApollo>
);
}
export default App;
You can see me importing a WithApollo
provider. Code for that is in app/withApollo.tsx
import React from "react";
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
import { WebSocketLink } from "@apollo/client/link/ws";
const link = new WebSocketLink({
uri: `ws://localhost:4000/`,
options: {
reconnect: true,
},
});
const client = new ApolloClient({
link,
uri: "http://localhost:4000/",
cache: new InMemoryCache(),
});
const WithApollo = (props: React.PropsWithChildren<{}>) => {
const { children } = props;
return <ApolloProvider client={client}>{children}</ApolloProvider>;
};
export default WithApollo;
You can see a WebSocketLink
, which listens to websocket traffic and ApolloClient
which helps us with creating GraphQL request to server. Speaking of let's make one in app/App.tsx
import { gql, useQuery } from '@apollo/client';
const GET_HELLO = gql`
query {
hello
}
`;
function App() {
const { data } = useQuery(GET_HELLO);
console.log(data)
return (
<WithApollo>
// other providers and components
</WithApollo>
);
}
In console, you should see something like:
{
"hello" : "world"
}
Live cursors
When adding a new feature on the server I usually first define types in the schema and then I complete the feature in the resolver. In the schema, I added
const typeDefs = `
type Cursor {
id: ID!
name: String!
x: Float!
y: Float!
}
input CursorInput {
id: ID!
name: String!
x: Float!
y: Float!
}
type Query {
cursors: [Cursor!]
}
type Mutation {
updateCursor(c: CursorInput!): ID!
deleteCursor(id: ID!): ID!
}
type Subscription {
cursors(c: CursorInput): [Cursor!]
}
There's no addCursor
mutation, because that's done via subscription, speaking of, to resolver, I added
import type { Context } from "./server";
interface Cursor {
id: string;
name: string;
x: number;
y: number;
}
const cursors: { [id: string]: Cursor } = {};
const cursorsSubscribers: (() => void)[] = [];
const onCursorsUpdates = (fn: () => void) => cursorsSubscribers.push(fn);
const spreadCursors = () => cursorsSubscribers.forEach((fn) => fn());
const generateChannelID = () => Math.random().toString(36).slice(2, 15);
const resolvers = {
Query: {
cursors: () => [...Object.values(cursors)],
},
Mutation: {
updateCursor: (_: any, args: { c: Cursor }) => {
if (!args?.c?.id) return "";
cursors[args.c.id] = {
...cursors[args.c.id],
...args.c,
};
spreadCursors();
return args.c.id;
},
deleteCursor: (_: any, args: { id: string }) => {
if (!args?.id) return "";
delete cursors[args.id];
spreadCursors();
return args.id;
},
},
Subscription: {
cursors: {
subscribe: (
_: any,
args: { c: Cursor },
{ pubsub }: { pubsub: Context["pubsub"] }
) => {
const channel = generateChannelID();
if (!args.c.id) return;
cursors[args.c.id] = { ...args.c };
onCursorsUpdates(() =>
pubsub.publish(channel, { cursors: [...Object.values(cursors)] })
);
setTimeout(
() =>
pubsub.publish(channel, {
cursors: [...Object.values(cursors)],
}),
0
);
return pubsub.asyncIterator(channel);
},
},
},
};
There's some
any
types, because I did not set up codegen, and also I do not use that first argument (hence the_
).
That's the server setup. The subscription is listening for when the new cursor is added through the client. updateCursor
updates the position of the cursor, when the user moves the mouse and lastly, the cursor is deleted when a client leaves the app (closes the tab).
Let's add these events to client, but first I want to bring up an article that helped me understand animating cursors (I also copied their cursor code). You can read it here
The code for cursor is here
// components/Cursor.tsx
function CursorSvg({ color }: { color: string }) {
return (
<svg
width={CURSOR_SIZE}
height={CURSOR_SIZE}
viewBox="0 0 24 36"
fill="none"
>
<path
fill={color}
d="M5.65376 12.3673H5.46026L5.31717 12.4976L0.500002 16.8829L0.500002 1.19841L11.7841 12.3673H5.65376Z"
/>
</svg>
);
}
First, I created a collab area, where all cursors, including the current user's cursor, will be
// components/CollabArea.tsx
interface Cursor {
id: string;
name: string;
x: number;
y: number;
}
interface Cursors {
cursors: Cursor[];
}
const CURSORS = gql`
subscription ($cursor: CursorInput!) {
cursors(c: $cursor) {
id
name
x
y
}
}
`;
const CollabArea = (props: React.PropsWithChildren<{}>) => {
const { children } = props;
const [currentUser, setCurrentUser] = React.useState({ id: "", name: "" });
const { data } = useSubscription<Cursors>(CURSORS, {
variables: {
cursor: { id: currentUser.id, name: currentUser.name, x: 0, y: 0 },
},
shouldResubscribe: !!currentUser.id,
skip: !currentUser.id,
});
return (
<React.Fragment>
/* this can be a simple form, where users will type their names, the ID can be done with simple Date.now().toString(), at least for now (probably not in production) */
<UserEnter setCurrentUser={setCurrentUser} />
{data?.cursors.map((c) => {
const posX = c.x * window.innerWidth;
const posY = c.y * window.innerHeight;
return (
<Cursor
key={c.id}
id={c.id}
name={c.name}
x={posX}
y={posY}
/>
);
})}
{children}
</React.Fragment>
);
};
The CollabArea
is a wrapper containing all cursors. With the help of useSubscription
I could subscribe to the server and now every time, there will be a change on the server, involving cursors, the server will send the updated data to the client. To display the cursor I've made a component called (you guessed it) Cursor
// components/Cursor.tsx
import { motion, useMotionValue } from "framer-motion";
const CURSOR_SIZE = 30;
const Cursor = (
{ id, name, x, y, current } = {
id: "0",
name: "",
x: 0,
y: 0,
}
) => {
const posX = useMotionValue(0);
const posY = useMotionValue(0);
React.useEffect(() => {
posX.set(x - CURSOR_SIZE / 2);
}, [x]);
React.useEffect(() => {
posY.set(y - CURSOR_SIZE / 2);
}, [y]);
/* you can get color however you like, even randomly */
const color = getColor(name);
return (
<motion.div
style={{
top: "0",
left: "0",
position: "absolute",
zIndex: "999999999",
pointerEvents: "none",
userSelect: "none",
transformOrigin: "left",
}}
initial={{ x: posX.get(), y: posY.get() }}
animate={{ x: posX.get(), y: posY.get() }}
transition={{
type: "spring",
damping: 30,
mass: 0.8,
stiffness: 350,
}}
>
<CursorSvg color={color} />
</motion.div>
);
};
Now, I have these cursors changing their position, when x
or y
coordinates change, but they're not changing ๐
. All the cursors are now in the position [0, 0]. To update their positions I'll create a hook
// components/CollabArea.tsx
import { throttle } from "throttle-typescript";
const UPDATE_CURSOR = gql`
mutation ($cursor: CursorInput!) {
updateCursor(c: $cursor)
}
`;
const useUpdateCursor = (id: string, name: string) => {
const [updateCursor] = useMutation(UPDATE_CURSOR);
const [visible, setVisible] = React.useState(false);
const hideCursor = () => setVisible(false);
const showCursor = () => setVisible(true);
const onMouseMove = (e: MouseEvent) => {
const posX = e.clientX;
const posY = e.clientY;
const serverPosition = {
x: posX / window.innerWidth,
y: posY / window.innerHeight,
};
updateCursor({
variables: {
cursor: {
id,
name,
...serverPosition,
},
},
});
};
const onThrottledMouseMove = React.useCallback(throttle(onMouseMove, 30), [
id,
]);
React.useEffect(() => {
document.addEventListener("mousemove", onThrottledMouseMove);
document.addEventListener("mouseleave", hideCursor);
document.addEventListener("mouseenter", showCursor);
return () => {
document.removeEventListener("mousemove", onThrottledMouseMove);
document.removeEventListener("mouseleave", hideCursor);
document.removeEventListener("mouseenter", showCursor);
};
}, [id]);
return { visible };
};
Here, I'm defining mutation and on each cursor move, I call it. I added an extra state for when the user leaves the collab area via visible
state. To use it just add it to CollabArea
component.
const CollabArea = (props: React.PropsWithChildren<{}>) => {
...
const { visible } = useUpdateCursor(currentUser.id, currentUser.name);
...
return (
...
{data?.cursors.map((c) => {
const posX = c.x * window.innerWidth;
const posY = c.y * window.innerHeight;
/* check if cursor belongs to current user */
const isCurrent = currentUser.id === c.id;
/* if it does and it's not visible don't display the cursor */
if (isCurrent && !visible) return null;
return (
<Cursor
key={c.id}
id={c.id}
name={c.name}
current={isCurrent}
x={posX}
y={posY}
/>
);
})}
)
}
Now, we can see the cursor updates when moving the mouse.
The movement will be a bit delayed. If you want your cursor to be "real-time" make a hook or use the state to achieve that, I kind of don't mind.
The last thing is to remove the cursor on the server when the user closes the tab. For that, I created another hook
const DELETE_CURSOR = gql`
mutation ($id: ID!) {
deleteCursor(id: $id)
}
`;
const useRemoveUser = (userID: string) => {
const [deleteCursor] = useMutation(DELETE_CURSOR);
const onUserLeaving = async (event: Event) => {
event.preventDefault();
await deleteCursor({
variables: {
id: userID,
},
});
return true;
};
React.useEffect(() => {
window.onunload = onUserLeaving;
return () => {
window.onunload = null;
};
}, [userID]);
return null;
};
Now, just add it to the CollabArea
and we're done with the live cursors.
const CollabArea = (props: React.PropsWithChildren<{}>) => {
...
useRemoveUser(currentUser.id);
...
}
Todos
So, todos are a little easier, mainly because we'll be implementing CRUD functionality. The todos will be saved in the database, so let's create a model
model Todo {
id String @id @default(auto()) @map("_id") @db.ObjectId
text String
is_completed Boolean
order Int
created_at DateTime @default(now())
updated_at DateTime @updatedAt
}
Let's continue with the schema. I kind of enjoy making these function definitions to get the bird's-eye view of the API or the server functionality.
const typeDefs = `
type Todo {
id: ID!
text: String!
is_completed: Boolean!
order: Int!
}
type Query {
...
messages: [Message!]
}
type Mutation {
...
addTodo(text: String!): ID!
updateTodo(id: ID!, is_completed: Boolean!): ID!
deleteTodo(id: ID!): ID!
}
type Subscription {
...
todos: [Todo!]
}
`;
The names are hopefully self-explanatory. Again, the Read portion of the app will be done via Subscription or Query. Let's first subscribe to todos.
const todosSubscribers: (() => Promise<boolean>)[] = [];
const onTodosUpdates = (fn: () => Promise<boolean>) =>
todosSubscribers.push(fn);
const spreadTodos = () => todosSubscribers.forEach(async (fn) => await fn());
const resolvers = {
Query: {
...
todos: async (
_: any,
_args: any,
{ prisma }: { prisma: Context["prisma"] }
) => await prisma.todo.findMany(),
},
Mutation: {
...
},
Subscription: {
...
todos: {
subscribe: async (_: any, _args: any, { pubsub, prisma }: Context) => {
const channel = generateChannelID();
// using function here, b/c if we would just get the todos, the result would be stale
const getTodos = async () => await prisma.todo.findMany();
onTodosUpdates(async () =>
pubsub.publish(channel, { todos: await getTodos() })
);
setTimeout(
async () => pubsub.publish(channel, { todos: await getTodos() }),
0
);
return pubsub.asyncIterator(channel);
},
},
},
};
Now, in components, I created TodosList
component, which displays all the todos.
// components/TodosList.tsx
interface Todo {
id: string;
text: string;
is_completed: boolean;
}
interface TodosQuery {
todos: Todo[];
}
const GET_TODOS = gql`
subscription {
todos {
id
text
is_completed
}
}
`;
const Todos = () => {
const { data } = useSubscription<TodosQuery>(GET_TODOS);
return (
// map over data.todos and display them
)
}
At first, there are no todos, unless the database was seeded. So, the next step will be adding an add todo functionality in resolvers.
const resolvers = {
...
Mutation: {
addTodo: async (_: any, { text }: { text: string }, ctx: Context) => {
const todos = await ctx.prisma.todo.findMany();
const created = await ctx.prisma.todo.create({
data: {
text,
is_completed: false,
order: todos.length,
},
});
spreadTodos();
return created.id;
},
},
...
};
The adding of todo is done via Prisma client and all we have to pass in is a text of the todo. On the client we define mutation like this
const ADD_TODO = gql`
mutation ($text: String!) {
addTodo(text: $text)
}
`;
const [postMessage] = useMutation(ADD_TODO);
and call it like this
postMessage({
variables: { text: some_text },
});
The delete and update functionality is pretty similar so I'll show them both at once
const resolvers = {
...
Mutation: {
...
updateTodo: async (
_: any,
{ id, is_completed }: { id: string; is_completed: boolean },
ctx: Context
) => {
await ctx.prisma.todo.update({
where: {
id,
},
data: {
is_completed,
},
});
spreadTodos();
return id;
},
deleteTodo: async (_: any, { id }: { id: string }, ctx: Context) => {
await ctx.prisma.todo.delete({
where: {
id,
},
});
spreadTodos();
return id;
},
},
};
And on the client we define mutations as
const UPDATE_TODO = gql`
mutation ($id: ID!, $is_completed: Boolean!) {
updateTodo(id: $id, is_completed: $is_completed)
}
`;
const DELETE_TODO = gql`
mutation ($id: ID!) {
deleteTodo(id: $id)
}
`;
const [updateTodo] = useMutation(UPDATE_TODO);
const [deleteTodo] = useMutation(DELETE_TODO);
Calling of mutations can be something like this
data.todos.map(({ id, text, is_completed }) => (
...
<label>
<input
onChange={async (e) =>
await updateTodo({
variables: {
id,
is_completed: e.target.checked,
},
})
}
checked={is_completed}
type="checkbox"
className="checkbox"
/>
</label>
...
<button
onClick={async () =>
await deleteTodo({
variables: {
id,
},
})
}
className="btn btn-circle btn-outline btn-error"
>
<CoolIcon />
</button>
...
)
And that's about it for Todos.
Conclusion
This was over how I implemented live cursors and todo app in GraphQL. The functionality can be, of course, extended by updating/deleting more todos at once, changing the order of todos, and adding codegen for better DX. I also added a chat feature.
Here's a github for the whole project, styles, and whatnot.
Posted on September 3, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
September 3, 2022