ReactSharedState

Using useSharedState

A hook that syncs the state across all clients. All clients see and (if permitted) modify the same "shared" state.

Accessing & Updating State

It works exactly the same as React's useState

const [state, setState] = useSharedState<boolean>(false);

// the first element has the state
state === false;

// this one sets the state
setState(true);

By default, the unique identifier for this piece of state (known as a channel) is set to be the pathname derived from window.location.href

An important limitation of this is that you can only have at most one SharedState without an explicitly defined channel on a page.

Functional Updates

When you have more complex state, it is more recommended to do functional updates instead because it is better suited to handle race conditions while syncing.

const [details, setDetails] = useSharedState({
    name: 'Mark S.',
    address: {
        city: 'Denver',
        state: 'CO'
    }
});

setDetails(details => {
    return {
        ...details,
        address: {
            ...details.address,
            city: 'Boulder'
        }
    };
});

Setting Initial State

Much like React's useState you can pass in both a raw state or a function that returns derived state.

const [state, setState] = useSharedState('Hello');
state === 'Hello';

const [state, setState] = useSharedState(() => 'Hello'.toUpperCase());
state === 'HELLO';

Explicitly Defining Channel

We allow you to not define a channel explicitly (by inferring it from pathname) to make your first experience with SharedState magical ✨, but it's highly recommended to always define a channel explicitly.

export function App() {
    const [message, setMessage] = useSharedState('Hello', {
        channel: 'UNIQUE IDENTIFIER FOR THIS PIECE OF STATE'
    });

    return <></>;
}

As mentioned above, channel is by default set to the current URL's pathname. You can only use one SharedState instance without the channel specified explicitly.

Validating State

As state can be set by the client side, it's possible to break your code accidentally by writing the wrong shape of state from two different components.

In the below example we're using zod, but you can welcome to use any schema validation library to validate the state.

import z from 'zod';

export function App() {
    const [message, setMessage, { error }] = useSharedState<string>('Hello', {
        channel: 'CHANNEL ID',

        validate: (rawState: any): string => {
            return z.string().parse(rawState);
        }
    });

    return (
        <pre>
            {error.message ?? message}
        </pre>
    );
}
  1. The generic argument for useSharedState and the return type of validate must match.
  2. If you skip the generic argument, the return type of validate will be inferred to be the type of state.

Handling Errors

If there ever happens to be an error either in validation or with the underlying mechanisms, error would be not null nor undefined, and the state will either be the last known "good" state or initial (computed) state.

const [state, setState, { error }] = useSharedState<string>('Hello', {
    validate: (rawState) => {
        if (typeof rawState !== 'string') {
            throw new Error('state must be a string');
        }

        return rawState;
    }
});

console.assert(state === 'Hello');

if (error) {
    // do something with `error`
}

The error property above will only contain the last error, and will be cleared as soon as another "good" state is received. If you want to persist errors, you are welcome to use the onError option to DIY.

const [storedErrors, setStoredErrors] = useState<any[]>([]);

const [state, setState] = useSharedState<string>('Hello', {
    onError(error) {
        setStoredErrors(errors => [...errors, error]);
    }
});

Underlying Connection Info

Sometimes it is necessary to be able to retrieve and act on the state of the underlying mechanisms involved.

export function App() {
    const [message, setMessage, { connected, started, synced, error }] = useSharedState<string>('Hello', {
        channel: 'CHANNEL ID',
    });
}
KeyTypeDescription
connectedbooleantrue if the underlying client connection is open.
startedbooleantrue if the underlying subscription on the server for channel is active.
syncedbooleantrue if the first sync of the underlying y.Doc has happened.
erroranyThe most recent error in the underlying connection or subscription or validation

It is recommended to start setting state after the first sync has occurred, i.e. when synced is true

Deferring Sync Start

Sometimes you might want to enable syncing after a delay maybe because you want to derive the channel via an asynchronous process. For such instances you may use the enabled option.

const [channel, setChannel] = useState<null | string>(null);

useEffect(() => {

    // for illustrative purposes of course.

    setTimeout(() => {
        setChannel('Realtime Hello');
    }, 1000);

}, []);

const [message, setMessage] = useSharedState('Hello', {
    channel: channel!,
    enabled: channel !== null
});

channel is only read once when the subscription is being started, hence the channel is read once when you set enabled to true, but changing channel after the fact will not change the channel being subscribed to.

Tokens & Security

By default, AirState servers allow all clients to read and write to any channel, but that can be locked down via config. If the default permissions don't permit reads and writes, your backend will need to provide a signed token.

You can pass this signed token from your backend to the useSharedState hook via the token option.

Literal String

// you can pass the token directly if
// you already have it

const [message, setMessage] = useSharedState('Hello', {
    token: 'TOKEN STRING'
});

Function

// you can pass a function that returns
// the (latest) token

const [message, setMessage] = useSharedState('Hello', {
    token: () => {
        return 'TOKEN STRING';
    }
});

Async Function

// that function can be async
// if you want

const [message, setMessage] = useSharedState('Hello', {
    token: async () => {
        const req = await fetch('/token/route');
        const resBody = await req.json();

        return resBody.token;
    }
});

Custom Client

By default, AirState's hooks will use the "default client" that is created the first time you invoke a subscription. This client is global and not easy to re-configure once already configured.

Having said that, you can still pass a different client created with createClient via the client option.

import {createClient} from "@airstate/client";

const differentClient = createClient({
    // client options
});

export function App() {
    const [state, setState] = useSharedState('', {
        client: differentClient 
    });

    return <></>;
}