Patterns
Saving
Choosing between explicit and autosave, showing the result and never losing what users entered.
Saving user data is the base of trust in a product. This pattern defines two paths: a form the user saves with a button, and a document that saves itself.
When to use
Section titled “When to use”- Explicit save: for forms with validation or side effects (settings, profile, creating a record).
- Autosave: for documents and notes that users type continuously.
- The two do not mix on one screen; a screen uses a single method.
Live example
Section titled “Live example”With explicit save, the button loads, then shows the success mark and a “Changes saved” toast. With autosave, saving starts 1000 ms after typing stops. In the failure stories the first attempt fails.
import { Button, InlineAlert, Inline, Stack, Text, TextInput, useToast } from '@bigbang/ui';import { useEffect, useRef, useState, type FormEvent } from 'react';import { type Lang, LangProvider } from '../lang';
const copy = { tr: { title: 'Proje ayarları', name: 'Proje adı', description: 'Ekip üyeleri bu adı listede görür.', save: 'Kaydet', saving: 'Kaydediliyor', saved: 'Değişiklikler kaydedildi', failedTitle: 'Kaydedilemedi', failed: 'Değişiklikler kaydedilemedi. Girdiğiniz bilgiler korundu; bağlantınızı denetleyip tekrar deneyin.', retry: 'Tekrar dene', initial: 'Proje 1', }, en: { title: 'Project settings', name: 'Project name', description: 'Team members see this name in the list.', save: 'Save', saving: 'Saving', saved: 'Changes saved', failedTitle: 'Not saved', failed: 'The changes were not saved. What you entered is kept; check your connection and try again.', retry: 'Try again', initial: 'Project 1', },};
type Status = 'idle' | 'saving' | 'saved' | 'failed';
/** How long the success mark stays on the button. */export const SUCCESS_HOLD = 2000;
type Props = { lang?: Lang; failFirst?: boolean; delay?: number };
function Form({ lang = 'tr', failFirst = false, delay = 1200 }: Props) { const t = copy[lang]; const toast = useToast(); const [value, setValue] = useState(t.initial); const [status, setStatus] = useState<Status>('idle'); const [mark, setMark] = useState(false); const [dirty, setDirty] = useState(false); const attemptsRef = useRef(0); const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => { const pending = timersRef.current; return () => pending.forEach(clearTimeout); }, []);
// Leaving with unsaved input asks the browser to confirm. useEffect(() => { if (!dirty) return; const guard = (event: BeforeUnloadEvent) => event.preventDefault(); window.addEventListener('beforeunload', guard); return () => window.removeEventListener('beforeunload', guard); }, [dirty]);
const save = () => { if (status === 'saving') return; setStatus('saving'); setMark(false); const attempt = attemptsRef.current++; timersRef.current.push( setTimeout(() => { if (failFirst && attempt === 0) { setStatus('failed'); return; } setStatus('saved'); // The toast announces the result once; the button's own success announcement is off (`successLabel=""`). toast.show({ title: t.saved, tone: 'success' }); setDirty(false); setMark(true); timersRef.current.push(setTimeout(() => setMark(false), SUCCESS_HOLD)); }, delay), ); };
const onSubmit = (event: FormEvent) => { event.preventDefault(); save(); };
return ( <form onSubmit={onSubmit} noValidate className="w-full max-w-md" aria-labelledby={`save-title-${lang}`}> <Stack gap="lg"> <Text weight="medium" id={`save-title-${lang}`}> {t.title} </Text> <TextInput label={t.name} description={t.description} name="project" value={value} onValueChange={(next) => { setValue(next); setDirty(true); if (status !== 'saving') setStatus('idle'); }} /> <Inline gap="md" align="center" wrap> <Button type="submit" loading={status === 'saving'} loadingLabel={t.saving} success={mark} successLabel=""> {t.save} </Button> </Inline> {status === 'failed' ? ( <InlineAlert tone="danger" title={t.failedTitle} announce actions={ <Button variant="secondary" size="sm" onClick={save}> {t.retry} </Button> } > {t.failed} </InlineAlert> ) : null} </Stack> </form> );}
/** * Explicit save: the button shows progress, then a success mark held for 2000 ms, and a success toast reports the result. * On failure the input stays, a danger `InlineAlert` says what happened and its "Try again" action repeats the save. * `failFirst` makes the first attempt fail so the failure path can be shown. */export function ExplicitSave(props: Props) { return ( <LangProvider lang={props.lang ?? 'tr'}> <Form {...props} /> </LangProvider> );}import { Button, InlineAlert, Inline, Spinner, Stack, Text, Textarea } from '@bigbang/ui';import { useEffect, useRef, useState } from 'react';import { type Lang, LangProvider } from '../lang';
const copy = { tr: { label: 'Toplantı notu', initial: 'Gündem: rapor 12 ve proje 3.', saving: 'Kaydediliyor…', saved: (time: string) => `Kaydedildi · ${time}`, failed: 'Kaydedilemedi', failedHint: 'Yazdığınız metin burada duruyor. Bağlantı gelince tekrar deneyin.', retry: 'Tekrar dene', }, en: { label: 'Meeting note', initial: 'Agenda: report 12 and project 3.', saving: 'Saving…', saved: (time: string) => `Saved · ${time}`, failed: 'Not saved', failedHint: 'Your text is still here. Try again when the connection is back.', retry: 'Try again', },};
type Status = 'idle' | 'saving' | 'saved' | 'failed';
/** The save starts this long after the user stops typing. */export const DEBOUNCE = 1000;
const clock = (date: Date) => date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
/** * Autosave: typing restarts a 1000 ms debounce; the status line shows progress and the time of the last save. * A failed save keeps the text and shows a warning `InlineAlert` with a retry action; a successful save does not toast. `failFirst` makes the first save fail. */export function Autosave({ lang = 'tr', failFirst = false, delay = 600 }: { lang?: Lang; failFirst?: boolean; delay?: number }) { const t = copy[lang]; const [text, setText] = useState(t.initial); const [status, setStatus] = useState<Status>('idle'); const [savedAt, setSavedAt] = useState(''); const latestRef = useRef(text); const attemptsRef = useRef(0); const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const workRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); const dirtyRef = useRef(false);
useEffect( () => () => { clearTimeout(debounceRef.current); clearTimeout(workRef.current); }, [], );
useEffect(() => { const guard = (event: BeforeUnloadEvent) => { if (dirtyRef.current) event.preventDefault(); }; window.addEventListener('beforeunload', guard); return () => window.removeEventListener('beforeunload', guard); }, []);
const save = (snapshot: string) => { setStatus('saving'); const attempt = attemptsRef.current++; workRef.current = setTimeout(() => { // Text typed meanwhile starts its own debounce; this result is stale. if (latestRef.current !== snapshot) return; if (failFirst && attempt === 0) { setStatus('failed'); return; } dirtyRef.current = false; setSavedAt(clock(new Date())); setStatus('saved'); }, delay); };
const onChange = (next: string) => { setText(next); latestRef.current = next; dirtyRef.current = true; clearTimeout(debounceRef.current); clearTimeout(workRef.current); setStatus('idle'); debounceRef.current = setTimeout(() => save(next), DEBOUNCE); };
return ( <LangProvider lang={lang}> <Stack gap="sm" className="w-full max-w-md"> <Textarea label={t.label} rows={4} value={text} onValueChange={onChange} /> <div role="status" className="min-h-6"> {status === 'saving' ? ( <Inline gap="xs" align="center"> <Spinner size="xs" decorative /> <Text size="sm" tone="subtle"> {t.saving} </Text> </Inline> ) : status === 'saved' ? ( <Text size="sm" tone="success"> {t.saved(savedAt)} </Text> ) : null} </div> {status === 'failed' ? ( <InlineAlert tone="warning" title={t.failed} announce actions={ <Button variant="secondary" size="sm" onClick={() => save(latestRef.current)}> {t.retry} </Button> } > {t.failedHint} </InlineAlert> ) : null} </Stack> </LangProvider> );}| Rule | Value |
|---|---|
| Explicit save | For forms with validation or side effects. |
| Autosave | For documents and notes; starts 1000 ms after typing stops. |
| Button | loading while saving, success when done; the success mark stays 2000 ms. |
| Result | Explicit save: a success toast, “Changes saved”. Autosave: a quiet status line, role="status", “Saved · 10:00”; it does not toast on every save. |
| Failure | Input is never lost. Explicit save shows a danger InlineAlert, autosave a warning InlineAlert; both have a “Try again” action. |
| Leave guard | With unsaved data, beforeunload in the browser; a confirmation dialog inside the app. |
| Double announcement | The button’s own success announcement is off (successLabel=""); the toast announces the result. |
Behaviour
Section titled “Behaviour”- With explicit save, the user changes fields and presses “Save”.
- The button switches to loading and “Saving” is announced once.
- On success the button shows the success mark for 2000 ms and the toast says “Changes saved”. The button does not announce it again.
- On failure the field values stay. The danger
InlineAlertsays what happened and what to do; its “Try again” action sends the same save again. - With autosave, every keystroke resets the 1000 ms wait. When it ends, “Saving…” shows, then “Saved · 10:00”. If the save fails, a warning
InlineAlertappears with a “Try again” action. - Text typed while a save runs starts a new wait; the result of the old save does not overwrite that text.
For validation errors see the Forms and validation pattern.
Accessibility
Section titled “Accessibility”- With explicit save the success toast is announced politely; with autosave progress and result are announced politely in a
role="status"region. Typing is never interrupted. - A failed explicit save is announced with
role="alert"because the user must act. A failed autosave is a warning announced withannounce. - The success mark never carries the meaning alone; the toast text is always present.
- The “Try again” button is keyboard reachable and sits right under the text field.
- Leaving the page with unsaved data is tied to the browser confirmation.
Content
Section titled “Content”- State the result: “Changes saved”, “Not saved”.
- On failure say the input is kept: “What you entered is kept.”
- Write time in 24-hour short form: “Saved · 10:00”.
- Buttons start with a verb and have no period: “Save”, “Try again”.
Motion
Section titled “Motion”| Item | Motion | Preset |
|---|---|---|
| Save button | 0.97 scale on press; 70 ms down, 230 ms back | motion.press |
| Success mark | 0.6 to 1 over 240 ms, held 2000 ms | motion.success |
| Toast and status line | Toast in 240 ms; status line 110 ms fade | motion.toast, motion.reveal |
With reduced motion the scale is removed and the success mark appears static.
Components used
Section titled “Components used”Button, InlineAlert, Toast, TextInput, Textarea, Spinner. Related patterns: Loading and Error recovery.
For the in-app confirmation when leaving with unsaved changes use AlertDialog; see Confirmation.