Skip to content

Save theme preferences with toggles

This tutorial adds hosted end-user login to the React app from Tutorial 1: Control an app banner from the CLI, then saves a theme preference and a notifications toggle for each signed-in user.

Estimated time: 20 minutes.


What You'll Build

You will extend the tutorial-app from Tutorial 1 with per-user preferences controlled by SkyState user state:

  • A sign-in button that starts the hosted sign-in flow.
  • A "signing in" state shown while sign-in is completing.
  • An authenticated greeting once the user is signed in.
  • A theme preference ('dark' or 'light') written immediately on click with set.
  • A notifications boolean toggle also written immediately with set.
  • A sign-out button that clears the end-user session.

Prerequisites

Before you start, make sure you have:

  • Completed Tutorial 1: Control an app banner from the CLI or scaffolded an equivalent Vite React app.
  • The tutorial-app project created in SkyState and your account route identifier from sky status.
  • The React SDK installed (npm install @skystate/react).
  • The Vite dev server running at http://localhost:5173/ (npm run dev -- --port 5173 --strictPort).

Step 1: Add callbackUrl to the provider

Open src/main.tsx and add a callbackUrl prop to SkyStateProvider:

tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { SkyStateProvider } from '@skystate/react';
import { App } from './App';
import './index.css';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <SkyStateProvider
      account="YOUR_ACCOUNT_ID"
      project="your-tutorial-app"
      environment="development"
      callbackUrl="http://localhost:5173/"
    >
      <App />
    </SkyStateProvider>
  </StrictMode>,
);

Replace YOUR_ACCOUNT_ID with the acc_... account ID from sky status, and your-tutorial-app with the project slug you chose in Tutorial 1. The callbackUrl must match the Vite dev-server origin exactly, including the trailing slash. After the hosted login page redirects back to this URL, SkyStateProvider completes the sign-in.


Step 2: Register the callback URL and enable end-user auth

A project created with sky project create starts with end-user auth disabled and no callback URLs registered. Register the callback URL first, then enable auth:

bash
sky project auth callback-urls add \
  --project your-tutorial-app \
  --url http://localhost:5173/ \
  --env development
sky project auth enable --project your-tutorial-app

Register the callback URL before enabling, because sky project auth enable rejects projects with no registered callback URL. Both steps are required before sign-in can work.


Step 3: Add the auth gate

useAuth() returns the current sign-in state. The status field has three values: 'unauthenticated', 'authenticating' (sign-in in progress), and 'authenticated'. Handle all three branches before rendering any user-state UI.

Open src/App.tsx and replace its contents:

tsx
import { useAuth, usePublicState } from '@skystate/react';

type AppConfig = {
  banner: {
    visible: boolean;
    message: string;
  };
  support: {
    label: string;
    email: string;
  };
  limits: {
    maxSavedViews: number;
  };
};

const fallbackConfig: AppConfig = {
  banner: { visible: false, message: '' },
  support: { label: 'Email support', email: 'support@example.com' },
  limits: { maxSavedViews: 3 },
};

function Banner() {
  const { value: config } = usePublicState<AppConfig>('appConfig', fallbackConfig);
  if (!config.banner.visible) return null;
  return <div className="banner">{config.banner.message}</div>;
}

export function App() {
  const auth = useAuth();

  if (auth.status === 'authenticating') {
    return <p>Signing in...</p>;
  }

  if (auth.status === 'unauthenticated') {
    return (
      <main>
        <Banner />
        <p>Sign in to save your preferences.</p>
        <button type="button" onClick={() => { void auth.loginWithRedirect(); }}>
          Sign in
        </button>
      </main>
    );
  }

  // auth.status === 'authenticated'
  return (
    <main>
      <Banner />
      <p>Signed in. Add preferences in Step 4.</p>
      <button type="button" onClick={() => { void auth.logout(); }}>
        Sign out
      </button>
    </main>
  );
}

The Banner component reads appConfig.banner from Tutorial 1's public state so the banner keeps working as you extend the app. The 'authenticating' branch covers the brief window after the user returns from the hosted login page while sign-in is completing. Handling it explicitly prevents the "Sign in" button from flashing during that window.

auth.loginWithRedirect() is async and returns a Promise<void>. Call it with void to mark the promise as intentionally ignored in the event handler.


Step 4: Read and write user preferences

Add useUserState for the theme and notifications keys. Replace src/App.tsx with the full version:

tsx
import { useAuth, usePublicState, useUserState } from '@skystate/react';

type AppConfig = {
  banner: {
    visible: boolean;
    message: string;
  };
  support: {
    label: string;
    email: string;
  };
  limits: {
    maxSavedViews: number;
  };
};

const fallbackConfig: AppConfig = {
  banner: { visible: false, message: '' },
  support: { label: 'Email support', email: 'support@example.com' },
  limits: { maxSavedViews: 3 },
};

function Banner() {
  const { value: config } = usePublicState<AppConfig>('appConfig', fallbackConfig);
  if (!config.banner.visible) return null;
  return <div className="banner">{config.banner.message}</div>;
}

export function App() {
  const auth = useAuth();

  if (auth.status === 'authenticating') {
    return <p>Signing in...</p>;
  }

  if (auth.status === 'unauthenticated') {
    return (
      <main>
        <Banner />
        <p>Sign in to save your preferences.</p>
        <button type="button" onClick={() => { void auth.loginWithRedirect(); }}>
          Sign in
        </button>
      </main>
    );
  }

  return <Preferences onSignOut={auth.logout} />;
}

function Preferences({ onSignOut }: { onSignOut: () => Promise<void> }) {
  const { value: theme, set: setTheme } = useUserState<string>('theme', 'dark');
  const { value: notifications, set: setNotifications } = useUserState<boolean>(
    'notifications',
    true,
  );

  return (
    <main>
      <Banner />
      <h1>Preferences</h1>

      <label>
        Theme
        <select
          value={theme}
          onChange={(e) => { void setTheme(e.currentTarget.value); }}
        >
          <option value="dark">Dark</option>
          <option value="light">Light</option>
        </select>
      </label>

      <label>
        <input
          type="checkbox"
          checked={notifications}
          onChange={(e) => { void setNotifications(e.currentTarget.checked); }}
        />
        Email notifications
      </label>

      <button type="button" onClick={() => { void onSignOut(); }}>
        Sign out
      </button>
    </main>
  );
}

useUserState<string>('theme', 'dark') reads the theme key for the signed-in user. The fallback 'dark' gives the UI a typed value before the user has saved anything. Each set call enqueues a write immediately and the UI reflects the new value optimistically, but the value is only durable once the server has confirmed the write. If you reload the page before the write completes, the server-side value is what you see.

The set function from useUserState accepts either a direct value or a functional updater that receives the previous committed value:

tsx
// Direct value
setTheme('light');

// Functional updater that toggles a boolean
setNotifications((prev) => !prev);

Use the direct form for select inputs where you read the selected value from the event. Use the functional form for toggles and counters where the new value depends on the current one.


Step 5: Run the app

Start Vite if it is not already running:

bash
npm run dev -- --port 5173 --strictPort

Open http://localhost:5173/.


Verify

  1. The app shows "Sign in to save your preferences." with a Sign in button.
  2. Click Sign in. The browser redirects to the hosted login page at auth.skystate.io.
  3. Authenticate through the hosted login page.
  4. The browser returns to http://localhost:5173/. The app shows the Preferences UI.
  5. Change the Theme select to Light and reload the page. The select still shows Light.
  6. Check and uncheck Email notifications and reload. The value persists.
  7. Click Sign out and confirm the sign-in button reappears.
  8. Sign in again as a different user. That user has separate theme and notifications values.

If the redirect does not return to your app, confirm the callbackUrl in the provider and the registered URL in Step 2 match exactly, including the trailing slash.


What You Learned

  • SkyStateProvider needs a callbackUrl prop that matches the registered callback URL exactly before hosted sign-in can complete.
  • sky project auth callback-urls add and sky project auth enable are both required on a new project.
  • useAuth() returns the current sign-in state. Handle all three auth.status values ('unauthenticated', 'authenticating', 'authenticated') before rendering user-state UI.
  • loginWithRedirect() starts the hosted end-user sign-in. logout() clears the end-user session.
  • useUserState<T>(key, fallback) returns { value, set, draft }. Use set for changes that save immediately.
  • Each signed-in user has an independent value for every key. Different users see their own saved preferences.

Where to Go Next