Frontlink: React realtime collaboration and updates with your backend

What if you could get that Github-like experience in your React app, like where you see PR comments pop up in real time?

What if you could turn useState() into useSharedState() in React, or create a function that when triggered, fires for all users looking at the same page?

Frontlink makes adding shared state into your React app as simple as a few lines of React, and you can plug right into your backed for custom room logic.

The frontlink DX was heavily inspired by driftdb, the major difference being that I wanted to bring my own backend (and maybe add a feature or two on top 😉).

Find the code on Github

Start by wrapping your React app

Integrating frontlink.dev starts with an npm i frontlink, and wrapping your React app in the <FrontlinkProvider> component:

import { FrontlinkProvider } from 'frontlink'

export default function App() {
	return (
		<FrontlinkProvider api={"wss://your-api.com/frontlink"}>
			<TheRestOfYourApp />
		</FrontlinkProvider>
	)
}

Only a few lines added, nice! (who counts imports anyway?)

We'll get back to the api prop in a bit, since we need to talk about bringing your own backend.

The great thing about frontlink is that at this point, nothing has happened! That's great because it means that even if frontlink is not connected, your app doesn't break, you just lose syncing.

We can start with a simple component:

export default function SomeSharedComponent() {

const { user } = getMyUser()
	const [value, setValue] = useState('my local value')

	return (
		<p>My room is {value}</p>
	)
}

But this component is pretty lonely, so let's get it sharing.

Now share some state

We can make a tiny tweak to make our state shared:

const [value, setValue] = useSharedState('someRoomName', 'my local value')

If there is some remote state that already exists, our local state will update. If anyone in the room calls setRoomName(), all clients will update to that value.

We can share functions too

State isn't the only thing that we can share!

Shared state must be serializable. This means that if you wanted to store something non-serializable in state, or you have logic that's more complex than just a single state variable, we can use shared functions:

import { useSharedState, useSharedFunction } from "frontlink"

export default function SomeSharedComponent() {

  const { user } = getMyUser()

  const [value, setValue] = useSharedState("someRoomName", "my local value")

  const sharedFunc = useSharedFunction("sharedFunc", async (someArg) => {
    console.log("I did something cool (hey... who triggered this?):", someArg)

  })

  return (
    <>
      <p>My room is {roomName}</p>
      <button
        onClick={() => {
          sharedFunc(42)
        }}
      >
        Click me to do something cool
      </button>
    </>
  )
}

Now when ever someone in the room calls sharedFunc, that will execute with the parameters for everyone locally. However, the parameters must be serializable.

For example maybe you need to reference something in the local window with an update that needs to be shared across the room, tell all clients about a roommates profile update, or do something based on user-specific info (like a user ID).

It could also be as simple as a data revalidation poke! Whenever the function fires, refresh the data, just like frameworks such as NextJS and Remix do with server actions. In fact, revalidation pokes is a simple and effective way to get that realtime update feel if you're already using ReactQuery or SWR.

Bringing your own backend

Ok so it's a little more work than just a few lines of React... But you get the point. You do have to do some work to make your API accept WebSockets and create room logic to handle message broadcasting. But, depending on your HTTP framework this can be quick, and you only need to add the functionality you want!

When a client first joins, they will emit a `SubscribeState ` for each useSharedState and a SubscribeFunction for each useSharedFunction. You can immediately emit a SetState event to that client to provide an initial value. This can either come from an existing in-memory state, or be pulled from a data store if they are the first client returning to a room. SubscribeState also includes the initial value of the client, which you can use to seed rooms.

This means you need to make sure signals like SetState or CallFunction get propagated to other clients in the room.

The first thing you probably want to add in is a way to map rooms to some synchronized state. Whether you want that to be in-memory on your API, pubsub with Redis, proxied to Cloudflare Durable Objects, or use something like StableInterfaces. However you make rooms is between you, your code, and your god.

Next, you might consider persistence. This is totally optional, frontlink doesn't care! But you might want to synchronize your in-memory state for a room with a data store on some interval, or even every change (I think StableInterface alarms are just perfect for that 😁).

Frontlink expects that joining a room will work regardless of auth state or permissions. It's up to you on the backend to determine whether it will be able to send/receive messages to a given room, but it will only ever subscribe when the component mounts.

Why BYOB (bring your own backend) is awesome

As nice as a managed service or a self-hosted open source backend is, there are a few reasons you might want to avoid that.

The most obvious one to me is consolidation. Get all your types/data structures, functions and tooling, API infrastructure, and data stores (e.g. your database or S3 bucket) instantly. This simplifies operations a LOT.

The second reason (that I've been feeling at lot lately as we scale at Tangia) is not having a dependency on a third party. Whether it's some open source code that isn't worth trying to decipher, fork, and fix, or a managed service's downtime and API compatibility changes out of your control, there are a lot of reasons to bring everything inside your understanding and control.

A third, and massively overlooked benefit, is how easy it is to make custom features.

Imagine you have some feature in your admin panel that you want your customer's dashboard updated in real time to. Well you can go add that right now! If that's something that wasn't already supported in the open source project or the managed service, well... good luck 🫡

Here's another idea: Maybe you only propagate certain function calls or state updates if an admin of an org or the owner of some document emits it.

Your server can even broadcast it's own CallFunction messages to trigger functions on connected clients!

Don't estimate the power of being able to quickly implement that thing you want (or need!) in an afternoon or less, it's basically the next best thing to the infinity stones. Sure it can mean a little extra work, but having full control is _the best_.

Some fun internal details

Everything is a Message

Every time something happens, we send a signal payload to all clients (except the invoking client). These are either StateUpdate or CallFunction with the appropriate payload to provide to roommates.

A Message is as simple as:

export interface Message {
  /**
   * A distinct ID generated by the client for deduplication
   */
  MessageID: string
  MessageType: MessageType

  /**
   * Added by the server (so all clients have a timestamp to "agree" on.
   * This means that a client with a wonky clock will only impact itself,
   * not all other clients!
   */
  MessageMS: number

  /**
   * Added by the server, ID of the invoking client (undefined if sent by server)
   */
  ClientID?: string
  RoomID: string

  /**
   * If a `MessageType = 'StateUpdate'`, what that value is,
   * with `JSON.stringify()` called on it
   */
  Value?: string

  /**
   * If a `MessageType = 'CallFunction', the parameters of the called function
   */
  Args?: string[]

}

Update deduplication (idempotency)

Because networks are funny, and code is buggy, we have to make sure that if a WebSocket message is sent to a client twice it doesn't execute twice.

We handle this with a simple in-memory set that checks for a specific MessageID that is generated on the client for every message. Unlike time, this is safe to generate on the client, and it's immediately added to the local dedupe set to safeguard against a backend that sends messages back to the emitting client.

Uniquely naming

In order to prevent errors and potential undefined behavior of naming collisions, frontlink will NOT let you attach multiple active shared states or functions with the same room name. Frontlink will error in the console, and emit a RoomCollisionPrevented event.

You can still break this system if you're not careful: For example if you name state the same on different pages that are not supposed to be shared. It's very important to give unique room IDs to all shared states and functions. Clients will ignore updates from the opposite type though, they are aware of state vs. functions.

A few good tips are:

1. Never use a shared state/function within a component that can have multiple of itself rendered at the same time: If you are listing something, put the shared state at the level above, not in the listed components.

2. Name things based on their components and functionality: Instead of useSharedState('count', 0), do something like useSharedState('SpecificButtonOrPageCount', 0) to prevent collisions.

Auth

Because the WebSocket API doesn't allow passing in headers, we have to look at some other mechanism for auth.

If you know your auth info at connection time (e.g. you are using something like <SignedIn> with Clerk) then you can pass a token as part of your WebSocket URL: wss://yourapi.com/ws?token=<TOKEN>. This method is greatly preferred, as you probably don't want unbound anonymous clients holding WebSocket connections.

Just for this purpose there is also a preConnect prop that returns a Promise<URLSearchParams>. This appends the search params to the provided URL can be used for both waiting on an initial token, and for getting a new token during reconnects.

return (

	<FrontlinkProvider
		api={`...`}
		preConnect={async () => {
			return new URLSearchParams({
				token: (await getToken()) ?? '<no token>',
			});
		}}
	>
		{props.children}
	</FrontlinkProvider>
);

Events

There's even have an EventEmitter based on the [events] package that you can import to listen for a comprehensive set of events:

import { Emitter, EventTypes, Messages } from "frontlink"

Emitter.on(
  EventTypes.RoommateSubscribe,
  (msg: Messages.RoommateSubscribedMessage) => {
    // Present
  }
)

// ... and many more

Across the board, frontlink doesn't throw unhandled promise rejections because having a package crash your app for something you can't catch is just not cool. Instead, events are emitted to notify if you are interested in handling them. Frontlink fails gracefully: it just falls back to local state.

(The one case it does mangle the frontend is when the WebSocket information is just bad like an invalid URL).

Use in production at Tangia

Frontlink is used at scale at Tangia.

We use Clerk for auth, and we wrap our app like this:

return (
	<FrontlinkProvider
		api={`${import.meta.env.VITE_BACKEND_API.replace('http', 'ws')}/frontlink`}
		preConnect={async () => {
			return new URLSearchParams({
				token: (await getToken()) ?? '<no token>',
			});
		}}
	>
		{props.children}
	</FrontlinkProvider>
);

Where the above code is just under the <SignedIn> component that wraps our dashboard.

Then we've changed simple function declarations from:

async function revalidateMediaShareQueue() {
    // ...
}

to:

const revalidateMediaShareQueue = useSharedFunction(
`revalidateMediaShareQueue::${localStorage.getItem('mod_for') ?? user?.id ?? '<not loaded yet>'}`,
    async () => {
        // ...
    },
);

Note: we check permissions on the backend to make sure they are allowed to connect to this room.

A funny final note

I actually wrote >90% of this post before even opening a terminal (I did check to make sure that frontlink was available on npm :D).

That may seem like a really strange decision, but hear me out:

Writing about how to use the package, without it actually existing first, forced that I design the exact possible developer experience I wanted - the best possible one. This forced me to make sure that the package worked for the developer using it, not the other way around.

It also meant that I had to prove to myself how great of an experience this was to use, otherwise it'd be a super lame post (obviously it's so interesting).

A crucial part of a successful open source project is telling a story to the developer about why this is important. Writing this post gave me a well-structured story, and craft a concise README.