Building a Collaborative Todo App w/ live cursors using GraphQL subscriptions
Tags: devjournal, webdev, graphql, showdev
Published: 2022-09-03 14:54:59

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](https://www.youtube.com/watch?v=E3NHd-PkLrQ) 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 😉


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3ogktolotbnzt1ia52ix.gif)


> 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 [here](https://github.com/Attanox/intrepid/blob/main/server/nodemon.json)

> For this project I used MongoDB. You can get free DB following [this link](https://www.prisma.io/blog/fullstack-remix-prisma-mongodb-1-7D0BfTXBmB6r#create-a-mongodb-instance). To work with DB, I love to use prisma. You can set up prisma following [this link](https://www.prisma.io/docs/getting-started/setup-prisma/add-to-existing-project/mongodb-typescript-mongodb)

For client set-up we can use [Vite](https://vitejs.dev/) by running 
```
yarn create vite client --template react-ts
```
> I also added `tailwind` via [this guide](https://tailwindcss.com/docs/guides/vite). 

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](https://liveblocks.io/blog/how-to-animate-multiplayer-cursors?utm_campaign=This%20Week%20In%20React&utm_medium=email&utm_source=Revue%20newsletter)

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](https://github.com/Attanox/intrepid) for the whole project, styles, and whatnot.