Skip to the code
cf.

Autosave a draft

Staff at the tea shop write tasting notes for new teas, and they shouldn't lose a note if the tab closes. Build App: a <textarea> labelled Tasting note that saves what's in it to localStorage as you type.

Save under the key tea-note-draft, 500 ms after the typing stops: every change starts the wait again, so a long burst of typing is saved once, at the end. An empty note is saved too, as an empty string.

Under the box, a <p role="status"> says how it's going: Saving… (with the single … character) from a change until it's saved, then Saved. Before the first change it's empty.

When App appears, the box starts with the saved draft, or empty if there isn't one. Loading a draft isn't a change: it doesn't save and the status stays empty. When App leaves the page, no timer is left running.

localStorage.setItem("tea-note-draft", "Malty, with honey.");
 
<App />
 
// renders, half a second after typing " Long finish.":
<label for="...">Tasting note</label>
<textarea id="...">Malty, with honey. Long finish.</textarea>
<p role="status">Saved</p>

What the tests check

  1. starts empty when nothing is saved
    render(<App />);
    expect(screen.getByLabelText("Tasting note")).toHaveValue("");
    expect(screen.getByRole("status").textContent).toBe("");
    
  2. loads the saved draft when it appears
    localStorage.setItem("tea-note-draft", "Malty, with honey.");
    render(<App />);
    expect(screen.getByLabelText("Tasting note")).toHaveValue("Malty, with honey.");
    
  3. doesn't save or say anything before the first change
    const clock = useFakeTimers();
    localStorage.setItem("tea-note-draft", "Malty, with honey.");
    render(<App />);
    await clock.advance(1000);
    expect(screen.getByRole("status").textContent).toBe("");
    expect(clock.pending()).toBe(0);
    
  4. shows Saving… while you type
    useFakeTimers();
    render(<App />);
    await user.type(screen.getByLabelText("Tasting note"), "Grassy");
    expect(screen.getByRole("status").textContent).toBe("Saving…");
    expect(localStorage.getItem("tea-note-draft")).toBe(null);
    
  5. saves 500 ms after you stop typing, then says Saved
    const clock = useFakeTimers();
    render(<App />);
    await user.type(screen.getByLabelText("Tasting note"), "Grassy");
    await clock.advance(499);
    expect(localStorage.getItem("tea-note-draft")).toBe(null);
    await clock.advance(1);
    expect(localStorage.getItem("tea-note-draft")).toBe("Grassy");
    expect(screen.getByRole("status").textContent).toBe("Saved");
    
  6. waits until the typing stops
    const clock = useFakeTimers();
    render(<App />);
    const note = screen.getByLabelText("Tasting note");
    await user.type(note, "Gra");
    await clock.advance(400);
    await user.type(note, "ssy");
    await clock.advance(400);
    expect(localStorage.getItem("tea-note-draft")).toBe(null);
    expect(screen.getByRole("status").textContent).toBe("Saving…");
    await clock.advance(100);
    expect(localStorage.getItem("tea-note-draft")).toBe("Grassy");
    
  7. goes back to Saving… for a change after Saved
    const clock = useFakeTimers();
    render(<App />);
    const note = screen.getByLabelText("Tasting note");
    await user.type(note, "Grassy");
    await clock.advance(500);
    await user.type(note, ", sweet");
    expect(screen.getByRole("status").textContent).toBe("Saving…");
    await clock.advance(500);
    expect(localStorage.getItem("tea-note-draft")).toBe("Grassy, sweet");
    expect(screen.getByRole("status").textContent).toBe("Saved");
    
  8. saves an emptied note
    const clock = useFakeTimers();
    localStorage.setItem("tea-note-draft", "Too bitter");
    render(<App />);
    await user.clear(screen.getByLabelText("Tasting note"));
    await clock.advance(500);
    expect(localStorage.getItem("tea-note-draft")).toBe("");
    expect(screen.getByRole("status").textContent).toBe("Saved");
    
  9. leaves no timer running when removed
    const clock = useFakeTimers();
    const { unmount } = render(<App />);
    await user.type(screen.getByLabelText("Tasting note"), "Grassy");
    unmount();
    expect(clock.pending()).toBe(0);
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).