DEV Community

Andrej Tlčina
Andrej Tlčina

Posted on

Building a Collaborative Todo App w/ live cursors using GraphQL subscriptions

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 😉

Image description

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
Enter fullscreen mode Exit fullscreen mode

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

For 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}/`);
});
Enter fullscreen mode Exit fullscreen mode

After running yarn dev we should get a log with the following:

Server on http://localhost:4000/
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

In console, you should see something like:

{
 "hello" : "world"
}
Enter fullscreen mode Exit fullscreen mode

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!]
  }
Enter fullscreen mode Exit fullscreen mode

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);
      },
    },

  },
};

Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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 };
};

Enter fullscreen mode Exit fullscreen mode

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}
          />
        );
      })}

  )
}

Enter fullscreen mode Exit fullscreen mode

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;
};

Enter fullscreen mode Exit fullscreen mode

Now, just add it to the CollabArea and we're done with the live cursors.

const CollabArea = (props: React.PropsWithChildren<{}>) => {
  ...
  useRemoveUser(currentUser.id);
  ...
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!]
  }
`;
Enter fullscreen mode Exit fullscreen mode

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);
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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
  )
}
Enter fullscreen mode Exit fullscreen mode

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;
},
Enter fullscreen mode Exit fullscreen mode

},
...
};

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);
Enter fullscreen mode Exit fullscreen mode

and call it like this

postMessage({
  variables: { text: some_text },
});
Enter fullscreen mode Exit fullscreen mode

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;
    },
  },
};

Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
...
)
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)