Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    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.

    • 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.

    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.

    Live example, StorybookOpen in Storybook
    Project settings

    Team members see this name in the list.

    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>
    );
    }
    Live example, StorybookOpen in Storybook
    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.
    DoThe error says the input is kept and offers a retry.
    Don'tClearing the field after a failure erases the user's work.
    1. With explicit save, the user changes fields and presses “Save”.
    2. The button switches to loading and “Saving” is announced once.
    3. On success the button shows the success mark for 2000 ms and the toast says “Changes saved”. The button does not announce it again.
    4. On failure the field values stay. The danger InlineAlert says what happened and what to do; its “Try again” action sends the same save again.
    5. With autosave, every keystroke resets the 1000 ms wait. When it ends, “Saving…” shows, then “Saved · 10:00”. If the save fails, a warning InlineAlert appears with a “Try again” action.
    6. 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.

    • 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 with announce.
    • 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.
    • 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”.
    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.

    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.