useUserState
useUserState reads, writes, and drafts user state for the project mounted by SkyStateProvider.
Authentication Setup
User state requires an authenticated end user. Put user-state UI behind an auth gate:
tsx
import { useStatus } from '@skystate/react';
export function UserStateAuthGate({ children }: { children: React.ReactNode }) {
const { auth } = useStatus();
if (auth.status !== 'authenticated') {
return <button onClick={auth.loginWithRedirect}>Sign in</button>;
}
return <>{children}</>;
}See useStatus for the full auth API.
Basic Examples
Read and update a value
tsx
import { useUserState } from '@skystate/react';
export function ThemeToggle() {
const { value: theme, set: setTheme } = useUserState('theme', 'dark');
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Theme: {theme}
</button>
);
}Draft a form before saving
tsx
import { useUserState } from '@skystate/react';
export function ProfileEditor() {
const { draft: profile } = useUserState('profile', { displayName: '' });
return (
<form
onSubmit={(event) => {
event.preventDefault();
profile.save();
}}
>
<input
value={profile.displayValue?.displayName ?? ''}
onChange={(event) => {
profile.set((current) => ({
...current,
displayName: event.target.value,
}));
}}
/>
<button type="submit" disabled={!profile.isPending}>Save</button>
<button type="button" onClick={profile.discard}>Discard</button>
</form>
);
}Show loading and errors
tsx
import { useStatus } from '@skystate/react';
export function UserStateGate({ children }: { children: React.ReactNode }) {
const { health } = useStatus();
if (health.status === 'loading') return <Skeleton />;
if (health.status === 'error') {
return <div>{health.error.message}</div>;
}
return <>{children}</>;
}API
ts
function useUserState<T = unknown>(
key: string,
): UseUserStateResult<T | null>
function useUserState<T>(
key: string,
fallback: T,
): UseUserStateResult<T | null>
type UseUserStateResult<V> = {
value: V;
set: (value: V | ((prev: V) => V)) => void;
clear: () => void;
syncStatus: 'unset' | 'syncing' | 'synced';
draft: UseUserStateDraft<V>;
};Both overloads return UseUserStateResult<T | null>: a fallback does not narrow value, because a stored null is returned as-is. set(undefined) is a type error on every overload; removal goes through clear().
Parameters
| Parameter | Description |
|---|---|
key | Top-level user-state key such as theme, profile, or preferences. |
fallback | Value returned while user state is not ready or when the key is absent. A stored null is returned as-is, never replaced by the fallback, so value stays T | null even with a fallback. |
Keys are top-level names. They cannot be empty and cannot contain / or ~.
Returns
| Call | Return |
|---|---|
useUserState<T>(key) | { value: T | null; set; clear; syncStatus; draft }. |
useUserState<T>(key, fallback) | { value: T | null; set; clear; syncStatus; draft }. |
set(value)
ts
set(value: T | null | ((prev: T | null) => T | null)): voidWrites a new value for the selected key. The visible value updates immediately. A rejected write reverts only its own optimistic value; other pending writes stay applied. The rejection is reported through onError; operational failures (network, server, rate limit, quota) also surface on useStatus().health. See Error Handling for the full per-status contract.
A value-form write whose value equals the current server-confirmed value is dropped before sending. The updater form always sends, so the server decides whether it is a no-op. Every issued write counts toward usage.
Functional updaters must be pure. The updater runs once at call time for the immediate visible update, then again at save time against the latest server-confirmed value, and it is re-run when the SDK refetches and replays the write after a version conflict (HTTP 412). Side effects in the updater repeat on every run.
clear()
ts
clear(): voidRemoves the key from user state. clear() is the only removal path: set(undefined) is a type error. Clearing a key that is already absent is a benign no-op.
syncStatus
Reports the sync state of the key's stored value, independent of any staged draft:
| Value | Meaning |
|---|---|
'unset' | The server has no value for this key; the stored value is the fallback (or null). |
'syncing' | A set() for this key is in flight; the stored value is the unconfirmed local write. |
'synced' | The stored value equals the last server-confirmed value. |
Draft Handle
ts
type UseUserStateDraft<T> = {
displayValue: T;
isPending: boolean;
set: (value: T | ((displayValue: T) => T)) => void;
save: () => void;
discard: () => void;
};| Member | Description |
|---|---|
displayValue | Drafted value when present, otherwise the committed value. Safe for controlled inputs. |
isPending | Whether a local draft exists. |
set(value) | Updates the local draft only. |
save() | Saves the current draft value. |
discard() | Drops the local draft value. |
Cross-Tab Synchronization
Committed writes synchronize across browser tabs automatically: when one tab's write is confirmed by the server, other same-origin tabs mounted on the same account, project, and environment refetch and pick up the new value. Only signed-in tabs react to these notifications. The SDK uses a BroadcastChannel for this; where BroadcastChannel is unavailable, it silently degrades to single-tab behavior.
Public Contract
| Contract | Detail |
|---|---|
| Auth required | User state reads and writes require a signed-in end user. |
| Top-level keys | Keys are top-level names, not nested paths. |
| Local update | set updates the visible value immediately. |
| Status | Use useStatus().health for loading and error state. |
| Errors | Load and save failures surface through onError; operational failures also flip useStatus().health; credential rejections end the session (see auth). |
| Metadata | Use the console, CLI, or REST API for inspection, audit, or history metadata. |