Settings Page in React: Tabs, Toggle Groups, Save Confirmation
Build a production-ready settings page in React with tabbed navigation, toggle groups, and a save confirmation pattern that actually feels right to users.
Why Settings Pages Are Harder Than They Look
Settings pages look boring on paper. A few tabs, some toggles, a save button — how complex can it get? Turns out, pretty complex. The state management alone can spiral fast once you're juggling per-tab dirty tracking, cross-section validation, and optimistic saves that need rollback on failure.
Most tutorials show you a happy-path skeleton and call it done. In practice, you end up handling: what happens when a user switches tabs with unsaved changes, whether to save per-section or globally, how to show inline feedback without a jarring full-page reload, and whether toggles should fire immediately or wait for an explicit save. These aren't edge cases — they're the whole UX.
Honestly, the 2024–2026 wave of design-heavy apps raised the bar here. Users now expect settings to feel as polished as the main product. A clunky form with a generic 'Settings saved' toast doesn't cut it anymore. You need visual feedback, logical grouping, and a tab system that doesn't lose state on switch.
This guide builds a real settings page — not a toy. We'll cover tab architecture, controlled toggle groups, a per-section save pattern, and the confirmation UX that closes the loop.
Tab Architecture: State, Not Routes
First decision: do you route to each tab (/settings/profile, /settings/notifications) or manage active tab in local state? Route-based tabs are better when users need deep links or bookmarks — think admin dashboards. State-based tabs are fine for SaaS settings where the URL is just /settings. Pick one and commit early.
For state-based tabs, keep tab selection in a single useState and store each section's form data separately. Don't stuff everything into one giant form object — it makes dirty-state tracking a nightmare. Here's the shell:
``tsx
type Tab = 'profile' | 'notifications' | 'appearance' | 'billing';
const [activeTab, setActiveTab] = useState<Tab>('profile');
const [profileData, setProfileData] = useState(initialProfile);
const [notifData, setNotifData] = useState(initialNotifications);
const [dirty, setDirty] = useState<Record<Tab, boolean>>({
profile: false,
notifications: false,
appearance: false,
billing: false,
});
``
The dirty map per tab is the key. When a user edits the profile form, you set dirty.profile = true. When they save, you reset it. This lets you show an unsaved indicator on the tab itself — a small dot or asterisk next to the label — which is a UX detail most devs skip and users really appreciate.
Worth noting: don't unmount inactive tab panels. Use CSS display: none or the hidden attribute to hide them instead. If you unmount, you lose local state and have to re-initialize from props every time the user switches back. That causes flicker and feels sluggish on slower connections.
Quick aside: if you're on React Router v7, you can get the best of both worlds with a layout route that renders tab panels as nested outlets, preserving scroll position and enabling back-button navigation. Check out the sidebar navigation patterns article for a similar approach applied to nav structures.
Building the Toggle Group Component
Toggle groups — the kind where only one option can be active at a time, like a segmented control — are different from a checkbox list. They're semantically closer to a radio group but styled as buttons. Get the ARIA right from the start: role="radiogroup" on the container, role="radio" on each item, aria-checked instead of aria-selected.
Here's a minimal but correct toggle group you can drop straight in:
``tsx
interface ToggleGroupProps<T extends string> {
options: { label: string; value: T }[];
value: T;
onChange: (value: T) => void;
name: string;
}
export function ToggleGroup<T extends string>({
options, value, onChange, name
}: ToggleGroupProps<T>) {
return (
<div role="radiogroup" aria-label={name} className="flex gap-1 p-1 bg-muted rounded-lg">
{options.map((opt) => (
<button
key={opt.value}
role="radio"
aria-checked={value === opt.value}
onClick={() => onChange(opt.value)}
className={[
'px-4 py-2 rounded-md text-sm font-medium transition-all',
value === opt.value
? 'bg-background shadow-sm text-foreground'
: 'text-muted-foreground hover:text-foreground'
].join(' ')}
>
{opt.label}
</button>
))}
</div>
);
}
``
The 40px height (roughly py-2 text-sm) is the sweet spot for toggle groups in settings. Go smaller and it's too fiddly on mobile. Go bigger and it dominates the section. You can swap the Tailwind classes for glassmorphism components if your app uses a frosted-glass aesthetic — the backdrop-filter approach works particularly well for appearance settings where users are already looking at background effects.
For multi-select toggle groups (where multiple options can be active), change the underlying model to Set<T> and flip aria-checked to reflect membership. Don't try to reuse the same component — write a separate ToggleGroupMulti to keep the types clean.
One more thing — keyboard nav. Native radio buttons handle arrow key navigation for free. Custom button-based toggle groups don't. You need to add onKeyDown handlers that move focus with arrow keys and select with Space/Enter. Without this, keyboard users will hate your settings page.
Form State Per Section and Dirty Tracking
Each tab section should manage its own form state independently. Don't reach for react-hook-form immediately — for settings pages with 5–15 fields per section, useState plus a custom useSettings hook is usually cleaner and easier to test. That said, if you already have RHF in your project, use it consistently. Mixing patterns is worse than either choice.
Here's how dirty tracking works in practice. Store the server-fetched data as savedData and the in-progress edits as localData. Compare them with a shallow diff:
``ts
function useSettingsSection<T extends object>(initial: T) {
const [saved, setSaved] = useState<T>(initial);
const [local, setLocal] = useState<T>(initial);
const isDirty = Object.keys(local).some(
(k) => local[k as keyof T] !== saved[k as keyof T]
);
const save = async (saveFn: (data: T) => Promise<void>) => {
await saveFn(local);
setSaved(local); // only after success
};
const discard = () => setLocal(saved);
return { local, setLocal, isDirty, save, discard };
}
``
The setSaved(local) call happens only after a successful API response — not optimistically. This is intentional. Settings errors on save are common (validation failures, network timeouts, permission errors) and rolling back is confusing to users who don't understand optimistic updates. Save confirmation only after you know it worked.
For nested objects in your form state, you'll want a setField helper to avoid spreading the whole object manually on every keystroke:
``ts
const setField = <K extends keyof T>(key: K, value: T[K]) =>
setLocal(prev => ({ ...prev, [key]: value }));
``
Look, the real win here isn't the code — it's the mental model. Treat each settings section like a tiny independent form. It has its own saved state, its own dirty flag, and its own save action. This also means you can save sections in parallel without them interfering, which matters if your backend has separate endpoints per settings category.
Save Confirmation UX: The Three Patterns
There are three save confirmation patterns in common use, and which one you pick depends on how destructive the settings are. Pattern one is the inline footer bar — a sticky bar at the bottom of the active section showing 'Unsaved changes' with Save and Discard buttons. This is what Vercel, Linear, and most modern SaaS tools use. It's visible without being intrusive.
Pattern two is the floating save bar — similar, but fixed to the bottom of the viewport, spanning the full width. GitHub used this in their settings before 2025. It's great for long sections where the user has scrolled past the initial save button. The downside: it covers content and feels heavy for small forms.
Pattern three is per-field auto-save with a checkmark confirmation — think Notion or Figma properties panel. Each field saves on blur or after a 600ms debounce. Works beautifully for single-value fields but falls apart on interdependent fields where partial state is invalid. Don't use this for forms with cross-field validation.
Here's the inline footer bar implementation:
``tsx
{isDirty && (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-border">
<span className="text-sm text-muted-foreground">
You have unsaved changes
</span>
<div className="flex gap-2">
<button
onClick={discard}
className="px-4 py-2 text-sm rounded-md border border-border hover:bg-muted"
>
Discard
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground disabled:opacity-50"
>
{isSaving ? 'Saving…' : 'Save changes'}
</button>
</div>
</div>
)}
``
After save succeeds, show a brief success state on the button — swap the label to 'Saved' with a checkmark for 1500ms before resetting. Don't fire a toast for routine settings saves. Toasts should be reserved for exceptional outcomes. A settings save is expected to work; the inline confirmation is enough. If it fails, then show a persistent error message inline (not a toast that auto-dismisses).
Appearance Section: Dark Mode Toggle Done Right
Appearance settings deserve special treatment because the changes are visible immediately. When a user switches from light to dark mode in your settings page, they should see the transition happen live — not after a save. This means the appearance section breaks the 'save to apply' pattern and instead applies changes on selection, with save persisting to the server.
Use a ToggleGroup for theme selection (Light / Dark / System) and apply the change to your theme context immediately on click. The save action just writes the preference to the API. If the user discards, revert the context to the saved value:
``tsx
const { local, setField, isDirty, save, discard } = useSettingsSection(savedAppearance);
// Apply immediately for live preview
useEffect(() => {
applyTheme(local.theme);
}, [local.theme]);
// Discard reverts the live preview too
const handleDiscard = () => {
discard();
applyTheme(savedAppearance.theme);
};
``
For color accent pickers in appearance settings, a 32px swatch grid beats a full color picker. Users aren't picking arbitrary colors — they're choosing from your brand palette. Limit it to 8–12 swatches, make them keyboard accessible with arrow keys, and you're done. You can pull palette inspiration from the gradient generator if you're building out a design-heavy settings page.
If you're building an app with multiple visual themes, the Empire UI component library has prebuilt style variants across aesthetics — from clean minimal to glassmorphism to neobrutalism — that you can wire directly into an appearance settings toggle group without building custom theme CSS from scratch.
Putting It All Together: Full Page Layout
The outer shell of a settings page is simple: a sidebar (or horizontal tab bar on mobile) on the left, content area on the right. On viewports below 768px, collapse the sidebar into a horizontal scrollable tab list at the top. Don't hide the section names on mobile — settings are already confusing enough without losing the nav.
Here's the skeleton of the complete page, which you can fill in with the pieces from the sections above:
``tsx
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState<Tab>('profile');
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-semibold mb-8">Settings</h1>
<div className="flex flex-col md:flex-row gap-8">
<nav className="flex md:flex-col gap-1 overflow-x-auto md:w-48 shrink-0">
{TABS.map((tab) => (
<TabButton
key={tab.id}
tab={tab}
active={activeTab === tab.id}
dirty={dirty[tab.id]}
onClick={() => setActiveTab(tab.id)}
/>
))}
</nav>
<div className="flex-1 min-w-0">
<div hidden={activeTab !== 'profile'}><ProfileSection /></div>
<div hidden={activeTab !== 'notifications'}><NotificationsSection /></div>
<div hidden={activeTab !== 'appearance'}><AppearanceSection /></div>
<div hidden={activeTab !== 'billing'}><BillingSection /></div>
</div>
</div>
</div>
);
}
``
The hidden attribute on the panel divs keeps them in the DOM (preserving state) while hiding them visually and from the accessibility tree — it's the right tool here, not display: none via class. React 18+ handles this without any extra work.
For the tab button, show a small 6px dot indicator when dirty is true for that tab. Something like a relative positioned <span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-amber-400" /> inside the button. It's a tiny touch but it tells users exactly which sections have pending changes at a glance.
Test the complete flow: edit profile, switch to notifications, edit there too, switch back to profile, save, switch to notifications, discard. If your state survives that sequence without corruption, you've got a solid settings page. Most settings bugs show up in exactly these multi-tab, multi-action sequences — not in isolated unit tests.
FAQ
It depends on what's already in your project. For pure settings pages, plain useState with a custom hook is often simpler. If you need complex cross-field validation or already use RHF elsewhere, use it consistently — mixing patterns causes more issues than either approach alone.
Use the beforeunload event for browser-level navigation and your router's block API (e.g., useBlocker in React Router v6+) for in-app navigation. Show a confirmation dialog only if any tab has isDirty: true.
Toggle groups are mutually exclusive — one selection at a time, like a radio group. Checkbox groups allow multiple selections. Use toggle groups for mode selection (Light/Dark/System) and checkbox groups for feature flags where multiple can be enabled simultaneously.
Cache the response in your data-fetching layer (React Query's staleTime, SWR's dedupingInterval) and only re-fetch on mount if the cache is older than a reasonable threshold — 30 seconds is usually fine for settings data that rarely changes from outside the current session.