TinyBase logoTinyBase

Todo App v6 (collaboration)

In this version of the Todo app, we use the PartyKit Persister to make the application collaborative.

We're making changes to the Todo App v5 (checkpoints) demo.

Server Implementation

To have a collaborative PartyKit experience, you need to deploy a server. TinyBase provides a ready-made PartyKit server that supports the synchronization and storage needed by client apps like this demo.

The server implementation for this demo is in the top level 'support' directory of the TinyBase repo for reference. But don't get too excited. It is literally just this:

import {TinyBasePartyKitServer} from 'tinybase/persisters/persister-partykit-server';

export default class extends TinyBasePartyKitServer {}

The TinyBasePartyKitServer class does all the work for synchronizing and storing data in the PartyKit cloud. Obviously if you need to enhance your server, you should just be able to extend that TinyBasePartyKitServer class accordingly.

For the purposes of this demo, this server has been deployed to partykit.dev, and so on the client, we need to set its address:

const PARTYKIT_HOST = 'partykit-todo-server.tinyplexbot.partykit.dev';

When running the PartyKit server locally, you would want to set this to '127.0.0.1:1999' or something similar.

Anyway, back to the rest of the client app...

Additional Initialization

To use the PartyKit Persister, we will need to add PartyKit itself as a dependency. For the purposes of this demo, we have compiled a UMD version of the PartySocket module:

 <script src="/umd/react.production.min.js"></script>
 <script src="/umd/react-dom.production.min.js"></script>
+<script src="/umd/partysocket.js"></script>

We also add the client portion of the Persister:

 <script src="/umd/persister-browser.js"></script>
+<script src="/umd/persister-partykit-client.js"></script>

We add both to the 'imports' so we can use them throughout the file:

 const {createLocalPersister, createSessionPersister} = TinyBasePersisterBrowser;
+const {createPartyKitPersister} = TinyBasePersisterPartyKitClient;
+const {default: PartySocket} = PartySocketModule;

Getting And Creating A Room

To make our application shareable, we need the concept of a 'room', which is basically a space on a PartyKit server with shared storage for multiple clients. We want to be able to create a room Id from this app that then updates the browser URL so that it can be shared with others.

There are many more sophisticated ways to this, but we are going for a simple approach of using the URL parameters to store a random room Id. We use a hook to store the room Id in the App state, and which gets the initial value. It also provides a function that creates a new room and updates the URL accordingly.

const useRoomId = () => {
  const [roomId, setRoomId] = useState(parent.location.search.substring(1));
  return [
    roomId,
    useCallback(() => {
      const newRoomId = ('' + Math.random()).substring(2, 12);
      parent.history.replaceState(null, null, '?' + newRoomId);
      setRoomId(newRoomId);
    }, []),
  ];
};

Note that this ID generation is, um, not very safe. Instead of a random number you should instead implement or import a true UUID generator if using this in anger!

Note also that we work with the parent location, rather than the window object. This is because the TinyBase demo runs in a trusted iframe and needs to get the URL from the outer page. Fortunately parent still resolves to window even when this isn't running in an iframe.

Persisting Into The Room

We use this new hook in the top level of the App component, and then create the PartyKit Persister. We make this conditional: if there is no room Id (yet), the useCreatePersister method returns nothing. Once a room Id exists, it will instead create the Persister, using the host of the PartyKit server and the room Id.

   useCreatePersister(
     store,
     (store) => createLocalPersister(store, 'todos/store'),
     [],
     async (persister) => {
       await persister.startAutoLoad(INITIAL_TODOS);
       checkpoints.clear();
       await persister.startAutoSave();
     },
     [checkpoints],
   );
+  const [roomId, createRoomId] = useRoomId();
+  useCreatePersister(
+    store,
+    (store) => {
+      if (roomId) {
+        return createPartyKitPersister(
+          store,
+          new PartySocket({host: PARTYKIT_HOST, room: roomId}),
+        );
+      }
+    },
+    [roomId],
+  );

Into this we also add an asynchronous 'then' function that will fire once the Persister is created. This will try to save the current local content to the room storage if it is empty (failing gracefully), and then load it if it is not. From here on, incremental changes are automatically saved and loaded over the websocket channel.

     },
     [roomId],
+    async (persister) => {
+      if (persister) {
+        await persister.startAutoSave();
+        await persister.startAutoLoad();
+        checkpoints.clear();
+      }
+    },
+    [checkpoints],
   );

As we did for local storage, we also reset the checkpoints so this process does not appear on the undo stack.

Adding A Share Button

All that remains is to give the user a way to create the room to start sharing! Let's add a single component called Share to do that. It takes the room Id value and function from the app-level state, and renders either a button to create a room and start sharing, or a link to the room that is already being shared to.

const Share = ({roomId, createRoomId}) => (
  <div id="share">
    {roomId ? (
      <a href={'?' + roomId} target="_blank">
        &#128279; Share link
      </a>
    ) : (
      <span onClick={createRoomId}>&#127880; Start sharing</span>
    )}
  </div>
);

We can add this to the top of the left-hand side of the app. For the sake of clarity, we remove the undo buttons for now:

-      <Title />
+      <Share roomId={roomId} createRoomId={createRoomId} />
       <NewTodo />
       <Types />
-      <UndoRedo />
       <Todos />
+      <Title />
       <StoreInspector />

Let's give it this share button some styling to make it prominent for this demo:

#share {
  a,
  span {
    background: #eee;
    border: @border;
    color: #000;
    cursor: pointer;
    display: inline-block;
    padding: 0.5rem 1rem;
    text-align: center;
    text-decoration: none;
    width: 10rem;
  }
  a {
    border-color: @accentColor;
    background: #ddd;
  }
}

And we are good to go! Clicking the 'Start sharing' button will add a query string to the URL and start sharing to PartyKit. Clicking the 'Share link' button will launch a new browser window with the same room Id in it.

As you can see, the results are synchronized, but that's also because the tabs of your browser are sharing the local storage we set up in a previous demo. A better demo is to launch a new window in incognito mode or even a completely different browser! If all goes well, you will still see the shared todo list.

Summary

We went from local-first to collaboration with just a few chunks of code and the magic of PartyKit. Party on!