Guides

Save React user settings across devices

Settings in localStorage stay in one browser. This tutorial builds a useJstashDoc hook so settings load when someone signs in and save 1 second after they stop changing them. Their theme, font size and preferences can then follow them across devices and browsers.

Written by jstash. Updated 19 October 2026.

How it works

jstash Apps stores private JSON documents for each signed-in person of a web app. Your app keeps its existing login. Here, the browser sends the person’s Clerk session token and jstash checks its signature against Clerk’s published keys, plus its issuer, expiry and the website it was issued on.

Each request reaches only that person’s documents. The hook uses a document named settings, so the same document name holds separate settings for each person. You don’t need to write a backend, set up a database, install a jstash SDK or write security rules. There’s no jstash API key in the page.

Set up your jstash app

This example assumes a Vite + React app with Clerk sign-in already in place. Configure jstash in the dashboard:

  1. Sign in at https://app.jstash.app with GitHub or Google.
  2. Open Apps → New app and pick Clerk.
  3. Paste your Clerk publishable key from Clerk → API keys.
  4. Add your website address, for example https://my-app.netlify.app. You can add up to 5 addresses. Turn on Allow localhost while developing.
  5. Copy the app ID (app_…) from the app’s page.

Install @clerk/react and add VITE_CLERK_PUBLISHABLE_KEY and VITE_JSTASH_APP_ID to .env.local as shown below. Neither value is a secret: both end up in the page every visitor loads.

npm install @clerk/react

# .env.local
VITE_CLERK_PUBLISHABLE_KEY=pk_test_…
VITE_JSTASH_APP_ID=app_…

Use Clerk’s default session token, not a JWT template. Clerk development and production instances are separate, so each needs its own jstash app.

Add the hook

Put this hook in src/useJstashDoc.js. It uses plain fetch, with nothing else to install.

import { useCallback, useEffect, useRef, useState } from 'react';

// One JSON document per signed-in person, stored in jstash Apps.
// endpoint: https://api.jstash.app/v1/apps/<your app ID>/me
// userId:   the signed-in person's ID, or null when nobody is signed in
// getToken: returns a fresh login token (Clerk: getToken from useAuth)
export function useJstashDoc(name, initialValue, { endpoint, userId, getToken, delay = 1000 }) {
  const [state, setState] = useState({ user: undefined, value: initialValue, status: 'loading', error: null, loaded: false });

  const currentUser = useRef(userId);
  const latest = useRef(initialValue);
  const loaded = useRef(false);
  const timer = useRef(null);
  const edits = useRef(0); // counts changes, so an older save can't report "ready" over a newer one
  const queue = useRef(Promise.resolve()); // saves go out one at a time, in order
  const initial = useRef(initialValue);
  const tokenFn = useRef(getToken);
  tokenFn.current = getToken;

  // Sends the request only if the same person is still signed in.
  const request = useCallback(async (forUser, init = {}) => {
    const token = await tokenFn.current();
    if (currentUser.current !== forUser) return null; // signed out or switched: drop it
    return fetch(`${endpoint}/${name}`, {
      ...init,
      headers: { ...init.headers, Authorization: `Bearer ${token}` },
    });
  }, [endpoint, name]);

  const save = useCallback((forUser) => {
    timer.current = null;
    const edit = edits.current;
    const body = JSON.stringify(latest.current);
    const set = (patch) => {
      if (currentUser.current === forUser) setState((s) => (s.user === forUser ? { ...s, ...patch } : s));
    };
    set({ status: 'saving' });
    queue.current = queue.current.then(async () => {
      try {
        const res = await request(forUser, { method: 'PUT', body });
        if (!res) return;
        if (!res.ok) throw await failure(res);
        if (edit === edits.current) set({ status: 'ready', error: null });
      } catch (err) {
        set({ status: 'error', error: err });
      }
    });
  }, [request]);

  // Unmounting (leaving the page in your app) sends a pending save at once instead of dropping it.
  // Declared before the load effect so it runs before that effect's cleanup clears the timer.
  const flush = useRef(null);
  flush.current = () => {
    if (timer.current && loaded.current) {
      clearTimeout(timer.current);
      save(currentUser.current);
    }
  };
  useEffect(() => () => flush.current(), []);

  // Load when someone signs in; reset when they sign out or switch.
  useEffect(() => {
    let cancelled = false;
    currentUser.current = userId;
    loaded.current = false;
    latest.current = initial.current;
    if (!userId) {
      setState({ user: null, value: initial.current, status: 'signed-out', error: null, loaded: false });
      return;
    }
    setState({ user: userId, value: initial.current, status: 'loading', error: null, loaded: false });
    request(userId)
      .then(async (res) => {
        if (!res || cancelled) return;
        let value = initial.current;
        if (res.ok) {
          value = await res.json();
        } else {
          const err = await failure(res);
          if (err.code !== 'DOC_NOT_FOUND') throw err; // DOC_NOT_FOUND: nothing saved yet
        }
        if (cancelled) return;
        latest.current = value;
        loaded.current = true;
        setState({ user: userId, value, status: 'ready', error: null, loaded: true });
      })
      .catch((err) => {
        if (!cancelled) setState({ user: userId, value: initial.current, status: 'error', error: err, loaded: false });
      });
    return () => {
      cancelled = true;
      clearTimeout(timer.current); // never save one person's changes as another's
      timer.current = null;
    };
  }, [userId, request]);

  // Update the screen now; save once changes stop for `delay` ms.
  const update = useCallback((next) => {
    const forUser = currentUser.current;
    if (!forUser || !loaded.current) return; // don't save defaults over data that hasn't loaded
    latest.current = typeof next === 'function' ? next(latest.current) : next;
    edits.current += 1;
    setState((s) => ({ ...s, value: latest.current, status: 'saving' })); // unsaved until the save finishes
    clearTimeout(timer.current);
    timer.current = setTimeout(() => save(forUser), delay);
  }, [save, delay]);

  // Until the load effect has run for a new person, show defaults, never the previous person's data.
  if (state.user !== (userId || null)) {
    return [initial.current, update, { status: userId ? 'loading' : 'signed-out', error: null, loaded: false }];
  }
  return [state.value, update, { status: state.status, error: state.error, loaded: state.loaded }];
}

// jstash errors are JSON: {"error": {"code", "message"}}
async function failure(res) {
  const body = await res.json().catch(() => null);
  const error = body?.error ?? {};
  return Object.assign(new Error(error.message || `jstash returned ${res.status}`), { code: error.code, status: res.status });
}

useJstashDoc takes a document name, an initial value and options containing endpoint, userId, getToken and delay. It returns [value, update, { status, error, loaded }]: a value and an update function like useState, plus the request state. loaded is true once the person’s saved copy has loaded or jstash has said nothing is saved yet.

When userId identifies a signed-in person, the hook loads their document with GET {endpoint}/{name}. If jstash returns 404 with DOC_NOT_FOUND, nothing has been saved yet and the hook keeps the defaults. Otherwise, it shows the saved copy.

update(next) accepts either a value or an updater function, like setState, and the screen updates at once. Once changes stop for delay milliseconds, the hook sends a PUT containing the whole document. The default delay is 1000 milliseconds: 1 second. A burst of slider changes therefore becomes one save instead of dozens. Saves go out one at a time, in order, and status stays 'saving' until the newest change is saved. If the component unmounts (the person leaves that page in your app) while a save is waiting, it is sent at once. Closing the tab within that 1 second can still lose the last change.

Before every request, the hook calls getToken() for a valid token and never stores one. Clerk tokens last 60 seconds. Clerk’s getToken() returns a valid token, refreshing it when needed.

The hook also handles account changes. When someone signs out or switches account, it drops any pending save and resets to the defaults. After an account switch, it never shows the previous person’s settings, not even for one render. It drops a request if the person changed while it was waiting for the token, so one person’s changes are never saved as another’s.

Changes are ignored until the saved copy has loaded. That prevents the initial defaults from overwriting settings the person already saved.

Use it in a component

Add the settings component in src/Settings.jsx.

import { useAuth } from '@clerk/react';
import { useJstashDoc } from './useJstashDoc.js';

const JSTASH = `https://api.jstash.app/v1/apps/${import.meta.env.VITE_JSTASH_APP_ID}/me`;
const DEFAULTS = { theme: 'light', fontSize: 16 };

export function Settings() {
  const { isLoaded, userId, getToken } = useAuth();
  const [settings, setSettings, { status, error, loaded }] = useJstashDoc('settings', DEFAULTS, {
    endpoint: JSTASH,
    userId: isLoaded ? userId : null,
    getToken,
  });

  if (!isLoaded) return <p>Loading your settings…</p>;
  if (status === 'signed-out') return <p>Sign in to keep your settings on every device.</p>;
  if (status === 'loading') return <p>Loading your settings…</p>;
  if (!loaded) return <p role="alert">Couldn't load your settings: {error.message}</p>;

  return (
    <form>
      <label>
        Theme{' '}
        <select value={settings.theme} onChange={(e) => setSettings((s) => ({ ...s, theme: e.target.value }))}>
          <option value="light">Light</option>
          <option value="dark">Dark</option>
        </select>
      </label>
      <label>
        Font size{' '}
        <input type="number" min="12" max="24" value={settings.fontSize}
          onChange={(e) => setSettings((s) => ({ ...s, fontSize: Number(e.target.value) }))} />
      </label>
      <p role="status">{status === 'saving' ? 'Saving…' : error ? `Not saved: ${error.message}` : 'Saved'}</p>
    </form>
  );
}

The component gets isLoaded, userId and getToken from Clerk’s useAuth(). It calls useJstashDoc with the name settings, defaults of theme: 'light' and fontSize: 16 and the endpoint https://api.jstash.app/v1/apps/${VITE_JSTASH_APP_ID}/me.

Signed-out visitors see “Sign in to keep your settings on every device.” While settings load, the component shows “Loading your settings…”. If loading fails (loaded is false), it shows “Couldn’t load your settings: <message>” instead of the form, so nobody edits settings that can’t be saved. Once settings load, it shows a theme select, a font-size input and a status line: “Saving…”, “Saved” or “Not saved: <message>”.

The hook’s status is 'signed-out', 'loading', 'ready', 'saving' or 'error'. Errors carry jstash’s code and message. FORBIDDEN means the website address isn’t on the app; its message names the address to add. AUTH_INVALID means an expired or wrong token. PAYLOAD_TOO_LARGE identifies a document that’s too large.

Wire the component into src/main.jsx with Clerk’s <ClerkProvider publishableKey=…>, <SignInButton /> or <UserButton /> and <Settings />.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ClerkProvider, SignInButton, UserButton, useAuth } from '@clerk/react';
import { Settings } from './Settings.jsx';

function App() {
  const { isSignedIn } = useAuth();
  return (
    <main>
      {isSignedIn ? <UserButton /> : <SignInButton />}
      <Settings />
    </main>
  );
}

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>
      <App />
    </ClerkProvider>
  </StrictMode>,
);

Use another login provider

With Supabase, Firebase or Google sign-in, only userId and getToken change. Supabase uses the session’s user ID and access_token; Firebase uses currentUser.uid and getIdToken(). Google uses the ID token from Sign In With Google, which lasts 1 hour and can’t be refreshed in the page. The jstash Apps guide has the exact lines and explains ETags (If-Match) for handling competing saves.

Limits and fit

  • There is no live sync. Changes aren’t pushed to devices already open. Each device loads the latest copy when the person signs in or the page loads. If 2 devices save at nearly the same moment, the last save wins.
  • PUT replaces the whole document; there are no partial updates. Saving identical content doesn’t count towards daily limits. No old versions are kept.
  • Free requires no card: 1 app, up to 100 users, 100 KB per document, 100 saves a day per user and 500 saves a day per app. A user counts once they first save. The limit of 60 saves a minute applies per user on every plan.
  • Each account also has a monthly allowance shared by its apps and bins: Free includes 5,000 writes and 50,000 reads a month; Pro includes 40,000 writes and 400,000 reads. App document saves count as writes and document reads count as reads. Monthly allowances will not be enforced before 17 November 2026, and jstash will announce enforcement before it starts.
  • Pro is US$5 a month, billed monthly and open to everyone: 3 apps, 1,000 users, 1 MB per document and 1,000 saves a day per user.
  • This isn’t for data people share with each other, search or queries across documents or an admin view of users’ data. The developer can’t read users’ documents. It isn’t for big or sensitive data, including health or similar sensitive personal data.

We tested the hook and the component with React 19, including StrictMode, against a local stand-in for the jstash Apps API that follows its documented responses, covering account switches, out-of-order saves, unmounting and errors. Clerk’s useAuth was replaced by a test double in those tests. main.jsx was built with Vite and @clerk/react 6 but not run against a live Clerk instance.

Create a free app

More guides