TinyBase logoTinyBase

Todo App v6 (collaboration)

In this version of the Todo app, we use a Synchronizer to make the application collaborative.

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

Server Implementation

To have a collaborative experience, you need to deploy a server that can forward the WebSocket messages between multiple clients. TinyBase provides a ready-made server for this purpose called WsServer, available in the synchronizer-ws-server module.

The server implementation for this demo is in the top level 'support' directory of the TinyBase repo for reference. At its heart, it is as simple as this:

import {WsServer} from 'tinybase/synchronizers/synchronizer-ws-server';
const server = createWsServer(new ws.WebSocketServer({port: 8043}));

The createWsServer function takes an instance of a WebSocketServer (from the well known ws library). This allows you to configure the underlying server in whatever ways you like.

Note that in this demo, the server is not saving a copy of the data itself - it is merely acting as a broker between clients. Nevertheless, such a configuration would be possible if you were building an application that needed a server 'source-of-truth'.

For the purposes of this demo, this server has been deployed to todo.demo.tinybase.org, and exposed on the HTTPS port of 443. On the client, we need to configure its address:

const WS_SERVER = 'wss://todo.demo.tinybase.org/';

With our server deployed, let's go back to the rest of the client app...

Only A MergeableStore Can Be Synchronized

Up until now, we have been using a regular TinyBase Store. But in order to synchronize data between clients, we need to upgrade that to be a MergeableStore so that it tracks the metadata required to merge without conflicts.

There is a simple change to the hook that creates the Store:

-const store = useCreateStore(() => createStore().setTablesSchema(SCHEMA));
+const store = useCreateMergeableStore(() =>
+  createMergeableStore().setTablesSchema(SCHEMA),
+);

Since a MergeableStore is fully compatible with the Store API, there are no other changes required within the app to accommodate this upgrade.

Additional Imports

To communicate with the server, we use a WsSynchronizer. This is in the synchronizer-ws-client module, and so we need to add that to our app:

 <script src="/umd/tinybase/persisters/persister-browser/index.js"></script>
+<script src="/umd/tinybase/synchronizers/synchronizer-ws-client/index.js"></script>

We import the function to create it accordingly:

 const {createLocalPersister, createSessionPersister} = TinyBasePersisterBrowser;
+const {createWsSynchronizer} = TinyBaseSynchronizerWsClient;

In turn, the WsSynchronizer is initialized with a WebSocket. This can be the browser's default implementation.

We need to add in the new Store creation function as described above (as well as a unique Id generator we'll be using):

-const {createCheckpoints, createIndexes, createMetrics, createStore} = TinyBase;
+const {
+  createCheckpoints,
+  createIndexes,
+  createMergeableStore,
+  createMetrics,
+  createStore,
+  getUniqueId,
+} = TinyBase;

And we do the same for the creation hook, as well as making sure we add the useCreateSynchronizer hook:

 const {
   CellView,
   CheckpointView,
   Provider,
   SliceView,
   useAddRowCallback,
   useCell,
   useCheckpoints,
   useCreateCheckpoints,
   useCreateIndexes,
+  useCreateMergeableStore,
   useCreateMetrics,
   useCreatePersister,
   useCreateStore,
+  useCreateSynchronizer,
   useMetric,
   useRedoInformation,
   useRow,
   useSetCellCallback,
   useSetCheckpointCallback,
   useSetValueCallback,
   useUndoInformation,
   useValue,
 } = TinyBaseUiReact;

Getting And Creating A Collaborative Space

To make our application shareable, we need a unique space on the server where multiple clients can see the same todo list. The server supports multiple of these spaces, and distinguishes between them simply by the path used when the WebSocket connects.

For example, one set of people might collaborate on a todo list brokered by wss://todo.demo.tinybase.org/1234, others on a todo list brokered by wss://todo.demo.tinybase.org/5678 - and so on.

We want to be able to create a unique Id from this app that can be used for the connection, and which updates the demo's browser URL so that it can be shared with others.

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

Helpfully, TinyBase provides a URL-safe unique Id generator, the getUniqueId function, that we can use:

const useServerPathId = () => {
  const [serverPathId, setServerPathId] = useState(
    parent.location.search.substring(1),
  );
  return [
    serverPathId,
    useCallback(() => {
      const newServerPathId = getUniqueId();
      parent.history.replaceState(null, null, '?' + newServerPathId);
      setServerPathId(newServerPathId);
    }, []),
  ];
};

Note that we work with the parent location, rather than the window object. This is simply 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.

Synchronizing to the server

We use this new hook in the top level of the App component, and then create the WsSynchronizer. We make this conditional: if there is no server path Id (yet), the useCreateSynchronizer method returns nothing. Once a server path Id exists, it will instead create the Synchronizer asynchronously, using the address formed by combining the host and the path itself.

Note that we are still persisting the data locally to local storage, but we put the MergeableStore in a different key to the Store from the previous demos (in case you go back to previous chapters and want the simpler Store's serialization to be still present).

   useCreatePersister(
     store,
-    (store) => createLocalPersister(store, 'todos/store'),
+    (store) => createLocalPersister(store, 'todos/mergeableStore'),
     [],
     async (persister) => {
       await persister.startAutoLoad([INITIAL_TODOS]);
       checkpoints?.clear();
       await persister.startAutoSave();
     },
     [checkpoints],
   );
+  const [serverPathId, createServerPathId] = useServerPathId();
+  useCreateSynchronizer(
+    store,
+    async (store) => {
+      if (serverPathId) {
+        const synchronizer = await createWsSynchronizer(
+          store,
+          new WebSocket(WS_SERVER + serverPathId),
+        );
+        await synchronizer.startSync();
+        checkpoints?.clear();
+        return synchronizer;
+      }
+    },
+    [serverPathId, 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 server path to start sharing! Let's add a single component called Share to do that. It takes the server path 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 = ({serverPathId, createServerPathId}) => (
  <div id="share">
    {serverPathId ? (
      <a href={'?' + serverPathId} target="_blank">
        &#128279; Share link
      </a>
    ) : (
      <span onClick={createServerPathId}>&#128228; 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
+        serverPathId={serverPathId}
+        createServerPathId={createServerPathId}
+      />
       <NewTodo />
       <Types />
-      <UndoRedo />
       <Todos />
+      <Title />
       <Inspector />

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 the WebSocket server. Clicking the 'Share link' button will launch a new browser window with the same server path 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 additions of code and the magic of TinyBase synchronization.