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
themepreference ('dark'or'light') written immediately on click withset. - A
notificationsboolean toggle also written immediately withset. - 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-appproject created in SkyState and your account route identifier fromsky 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-appRegister 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 --strictPortOpen http://localhost:5173/.
Verify
- The app shows "Sign in to save your preferences." with a Sign in button.
- Click Sign in. The browser redirects to the hosted login page at
auth.skystate.io. - Authenticate through the hosted login page.
- The browser returns to
http://localhost:5173/. The app shows the Preferences UI. - Change the Theme select to Light and reload the page. The select still shows Light.
- Check and uncheck Email notifications and reload. The value persists.
- Click Sign out and confirm the sign-in button reappears.
- Sign in again as a different user. That user has separate
themeandnotificationsvalues.
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
SkyStateProviderneeds acallbackUrlprop that matches the registered callback URL exactly before hosted sign-in can complete.sky project auth callback-urls addandsky project auth enableare both required on a new project.useAuth()returns the current sign-in state. Handle all threeauth.statusvalues ('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 }. Usesetfor 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
- Tutorial 3: Save notes for each user. Store and manage an array value with
useUserState. - Reference: useAuth and status hooks
- Reference: useUserState