Build a user settings form
This tutorial replaces the direct-write preference controls from earlier tutorials with a settings form that stages edits locally until the user clicks Save or Discard. The draft API lets users edit several fields and decide whether to keep or throw away those changes.
Estimated time: 25 minutes.
What You'll Build
You will add a Profile settings form to tutorial-app that:
- Reads a
profileuser-state key withuseUserState. - Binds controlled inputs to
draft.displayValue. - Stages local edits with
draft.setwithout writing to the network. - Saves staged edits with
draft.push()when the user clicks Save. - Discards staged edits with
draft.discard()when the user clicks Discard. - Gates Save and Discard on
draft.isPendingso they are disabled when nothing has changed.
Prerequisites
Before you start, make sure you have:
- Completed Tutorial 2: Save theme preferences with toggles. You need the provider configured with
callbackUrl, end-user auth enabled on the project, and theuseAuth()sign-in gate inApp. - The Vite dev server running at
http://localhost:5173/(npm run dev -- --port 5173 --strictPort).
Step 1: Read profile state
The profile key holds an object with name and bio fields. Destructure { draft } from useUserState because this form stages edits rather than writing each keystroke to the server.
Keep the full auth gate from Tutorial 2 and replace the authenticated content with a Profile component. Keep the Banner component from Tutorial 3 so the appConfig.banner remains visible throughout the tutorial sequence:
tsx
import { useAuth, usePublicState } from '@skystate/react';
import { Profile } from './Profile';
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 edit your profile.</p>
<button type="button" onClick={() => { void auth.loginWithRedirect(); }}>
Sign in
</button>
</main>
);
}
return (
<main>
<Banner />
<Profile />
<button type="button" onClick={() => { void auth.logout(); }}>
Sign out
</button>
</main>
);
}Step 2: Bind inputs to draft.displayValue
Create src/Profile.tsx. Use draft.displayValue as the controlled value for every input. draft.displayValue is "drafted-or-committed": it returns the staged draft value when one exists, or the last committed server value when it does not. Controlled inputs bound to it always show the right value regardless of edit state.
tsx
import { useUserState } from '@skystate/react';
type ProfileData = {
name: string;
bio: string;
};
const fallback: ProfileData = { name: '', bio: '' };
export function Profile() {
const { draft } = useUserState<ProfileData>('profile', fallback);
return (
<form>
<label>
Name
<input
value={draft.displayValue.name}
onChange={(e) =>
draft.set({ ...draft.displayValue, name: e.target.value })
}
placeholder="Your name"
/>
</label>
<label>
Bio
<textarea
value={draft.displayValue.bio}
onChange={(e) =>
draft.set({ ...draft.displayValue, bio: e.target.value })
}
placeholder="A short bio"
/>
</label>
</form>
);
}draft.set stages the new value locally without a network write. Each change copies draft.displayValue and replaces one field, so unmodified fields are preserved.
Step 3: Save and Discard
Add the form submit handler and the two action buttons. Save calls draft.push() to write the staged value to the network. Discard calls draft.discard() to drop the staged value and snap the inputs back to the last committed value.
Disable both buttons unless draft.isPending is true. draft.isPending is a draft-presence flag: it is true whenever a staged value exists, regardless of whether the staged value differs from the committed one.
tsx
import { useUserState } from '@skystate/react';
type ProfileData = {
name: string;
bio: string;
};
const fallback: ProfileData = { name: '', bio: '' };
export function Profile() {
const { draft } = useUserState<ProfileData>('profile', fallback);
return (
<form
onSubmit={(e) => {
e.preventDefault();
draft.push();
}}
>
<label>
Name
<input
value={draft.displayValue.name}
onChange={(e) =>
draft.set({ ...draft.displayValue, name: e.target.value })
}
placeholder="Your name"
/>
</label>
<label>
Bio
<textarea
value={draft.displayValue.bio}
onChange={(e) =>
draft.set({ ...draft.displayValue, bio: e.target.value })
}
placeholder="A short bio"
/>
</label>
<button type="submit" disabled={!draft.isPending}>
Save
</button>
<button
type="button"
disabled={!draft.isPending}
onClick={() => draft.discard()}
>
Discard
</button>
</form>
);
}Verify
- Open
http://localhost:5173/and sign in if prompted. - The form shows empty Name and Bio fields (no saved profile yet).
- Save and Discard are disabled.
- Type into Name. Both buttons become enabled.
- Click Discard. The input reverts to the previously saved value (empty for a new user). Both buttons become disabled.
- Type into Name and Bio again.
- Click Save.
- Reload the page. The saved values are still in the form.
What You Learned
useUserState('profile', fallback)returns{ value, set, draft }. Destructure{ draft }when building a form that stages edits.draft.displayValueis the controlled-input value. It returns the staged draft when one exists, or the committed value when it does not.draft.setstages local edits without a network write. Each call replaces the entire staged value, so spreaddraft.displayValueto preserve unmodified fields.draft.push()writes the staged value to the network and clears the draft slot.draft.discard()drops the staged value without a network write. Inputs snap back to the committed value.draft.isPendingistruewhenever a staged value exists. Use it to enable Save and Discard.