React realtime updates through your backend in a few lines of code. Heavily inspired by the driftdb DX.
You can also read the launch blog post
First, install the frontlink
package:
npm i frontlink
Next we need to wrap our components in a FrontlinkProvider
. You can do this as far down as you need, and you may want to wait until you have known user information so you can attach auth to your URL.
import { FrontlinkProvider } from "frontlink"
function App() {
return (
<FrontlinkProvider api="wss://yourapi.com">
<RestOfYourApp />
</FrontlinkProvider>
)
}
Now we can use shared state within components!
import { useSharedState } from "frontlink"
export default function SomeSharedComponent() {
const [value, setValue] = useSharedState("someRoomName", "my local value")
return <p>My room is {value}</p>
}
We can also used 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 {value}</p>
<button
onClick={() => {
sharedFunc() // this gets called on all clients
}}
>
Click me to do something cool
</button>
</>
)
}
You can find a minimal example React app here that includes a simple backend implementation.
Sometimes you don't want to have everyone else call a function when you do, like if you need to use the same function for polling a resource as you would for revalidating, or you need to update state based on something that only applies to the local client.
Every shared function and state setter have a .noEmit()
version of itself that allows you to execute the function without emitting it to roommates:
import { useSharedState, useSharedFunction } from "frontlink"
export default function SomeSharedComponent() {
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 (
<>
<button
onClick={() => {
sharedFunc.noEmit() // does not emit over websocket
}}
>
Click me to do something cool (by myself)
</button>
<button
onClick={() => {
setValue.noEmit("a new local value") // does not emit over websocket
}}
>
Update my local value
</button>
</>
)
}
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 however (they are aware what kind of share they have), so in theory you can share a name between a function and a state (but maybe don't anyway).
If you pass undefined
or null
as the state/function ID, it will abort connecting. You can then later pass a string to connect.
const [userID, setUserID] = useState<string | undefined>()
const [sharedState, setSharedState] = useSharedState(
userID ? `room::${userID}` : undefined
)
A few good tips are:
- 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.
- Name things based on their components and functionality: Instead of
useSharedState('count', 0)
, do something likeuseSharedState('SpecificButtonOrPageCount', 0)
to prevent collisions. - Use ARN-style room naming. For example
{roomType}::{uniqueID}
where theuniqueID
is something like a user ID or an organization ID. That way on room join you can split by::
and check permissions accordingly. - Use
null
orundefined
when you need to load into the state/function room after some other client-side initialization has occurred (like loading a user ID)
BBecause 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>
)
You can also use the undefined
or null
as mentioned above.
A comprehensive suite of events are emitted for your app to react in response to.
import { Emitter, Events, Messages } from "frontlink"
Emitter.on(Events.SocketOpened, (event: Event) => {
// connected to the socket
})
You can find a minimal backend here for an example React app with an Express API. This implements simple room subscribe/unsubscribe, and relaying events to clients in the room. That is all you need!
Messages are emitted to the backend as stringified JSON in the schema found in messages.ts
.
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, as it will only ever resubscribe if the component unmounts and remounts.
You should also use a single URL path (like /frontlink?token={jwt}
) for all connections, rather than being per-user or per-org when possible. Then you can manage scope and permissions to join rooms based on the provided token.
There are only a few critical pieces to building a minimal backend:
- Clients connect to websocket - assign them some ClientID
- Clients subscribe to a room - Store this client ID to that room until they unsubscribe ("room-client index"). Emit a
RoommateSubscribed
(see schema) message to all but the new client if presence is relevant - Clients emit
SetState
andCallFunction
messages to the backend. The backend should then relay these to all other connected clients in that room (do not send back to emitting client). Set theClientID
andMessageMS
of the messages. - When clients unsubscribe from a room, remove them from the room-client index. Emit a
RoommateUnsubscribed
(see schema) message to all but the new client if presence is relevant. - When clients disconnect, remove them from all room-client indexes
Clients will emit one or more SubscribeState
or SubscribeFunction
messages when joining a room. You should be able to deduplicate joins (ignore if they are already joined).
When clients disconnect, they should be removed from all rooms.
When a useSharedState
or useSharedFunction
is mounted, they will emit a SubscribeState
and SubscribeFunction
event respectively to the backend. For the SubscribeState
event, the payload includes the Value
property, which is the value of JSON.stringify(initialValue)
. You can use this to seed the state for the room, and for clients that subsequently connect.
You can also seed the state on the client by immediately emitting a SetState
message back to them to update their state.
You can choose to emit SetState
and CallFunction
messages from the server to subscribed clients. For example you may want real-time updates to clients based on actions from an admin panel, or some global event like a notification triggering a toast.
View messages.ts
for the schema of the JSON payload.
Ensure to set the MessageMS
to the current time, and leave ClientID
blank to indicate it's from the server (or set to some constant like "server"
).
Just remove them from the room-client index and drop incoming messages to that room if the client does not belong to the room.
If you would like to persist SetState
calls, it's best to process a room linearizably (in order) based on the order SetState
events are received by the backend. When a room is ressurected, you can restore the state from persistent storage.
You can use non-linearizable datastores (e.g. S3, most databases) by processing them in order from memory. Based on your requirements, you can choose to collapse them to some interval so you are only writing once per interval (e.g. once per second).
When a client subscribes to a room, you can immediately serve it a SetState
event to sync the state with the room.
Use the Emitter
import to listen for RoommateSubscribe
and RoommateUnsubscribe
:
import { Emitter, Events, Messages } from "frontlink"
Emitter.on(
Events.RoommateSubscribe,
(msg: Messages.RoommateSubscribedMessage) => {
// Present
}
)
Emitter.on(
Events.RoommateUnsubscribe,
(msg: Messages.RoommateUnsubscribedMessage) => {
// Removed
}
)