Skip to the code
cf.

Optimistic like with rollback

Build LikeButton, the default export, for a post on a small tea shop's blog. It takes postId (default 1), initialLiked (default false), and initialLikes (default 12). It shows a toggle button named Like with aria-pressed set to whether you like the post, and the count as 12 likes (always likes, to keep it simple).

A click flips the like at once: aria-pressed and the count change before the server answers, by one up or one down. The click also sends POST /api/posts/:postId/like with the JSON body { "liked": true } or { "liked": false }, the value the click asked for. The server answers with the saved state, like { liked: true, likes: 13 }, and once it has answered, show what it says.

If a save fails (fetch rejects, or the response isn't ok), that click is undone and Couldn't save your like. shows in an element with role="alert". The next click hides the message.

People click fast. The button stays enabled while saves are pending, and every click builds on what's on screen, so like, unlike, like sends true, false, true. While saves are pending, the screen shows the last saved state with every pending click on top. A failed click must never undo the clicks that came after it: when all saves have finished, the screen shows what the server last said.

<LikeButton postId={7} initialLiked={false} initialLikes={12} />
 
<button aria-pressed="true">Like</button>
<p>13 likes</p>

What the tests check

  1. shows the starting count and the like state from its props
    render(<App postId={7} initialLiked={true} initialLikes={30} />);
    expect(screen.getByRole("button", { name: "Like" })).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByText("30 likes")).toBeInTheDocument();
    
  2. a click shows the like at once, before the server answers
    const server = { liked: false, likes: 12 };
    const answers = [];
    const fetch = mockFetch((url, init) => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise.then((saved) => {
        if (!saved) return fail(500);
        const { liked } = JSON.parse(init.body);
        if (liked !== server.liked) server.likes += liked ? 1 : -1;
        server.liked = liked;
        return { ...server };
      });
    });
    render(<App postId={7} initialLiked={false} initialLikes={12} />);
    await user.click(screen.getByRole("button", { name: "Like" }));
    expect(answers).toHaveLength(1);
    expect(screen.getByRole("button", { name: "Like" })).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByText("13 likes")).toBeInTheDocument();
    
  3. sends POST /api/posts/:postId/like with the value the click asked for
    const server = { liked: false, likes: 12 };
    const answers = [];
    const fetch = mockFetch((url, init) => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise.then((saved) => {
        if (!saved) return fail(500);
        const { liked } = JSON.parse(init.body);
        if (liked !== server.liked) server.likes += liked ? 1 : -1;
        server.liked = liked;
        return { ...server };
      });
    });
    render(<App postId={7} initialLiked={false} initialLikes={12} />);
    const button = screen.getByRole("button", { name: "Like" });
    await user.click(button);
    expect(fetch.calls[0].url).toBe("/api/posts/7/like");
    expect(fetch.calls[0].method).toBe("POST");
    expect(JSON.parse(fetch.calls[0].body)).toEqual({ liked: true });
    answers[0].resolve(true);
    await flush();
    await user.click(button);
    expect(JSON.parse(fetch.calls[1].body)).toEqual({ liked: false });
    
  4. shows what the server says once it answers
    mockFetch(() => ({ liked: true, likes: 40 }));
    render(<App postId={7} initialLiked={false} initialLikes={12} />);
    await user.click(screen.getByRole("button", { name: "Like" }));
    expect(await screen.findByText("40 likes")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Like" })).toHaveAttribute("aria-pressed", "true");
    
  5. rolls back and shows an error when the save fails
    const server = { liked: false, likes: 12 };
    const answers = [];
    const fetch = mockFetch((url, init) => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise.then((saved) => {
        if (!saved) return fail(500);
        const { liked } = JSON.parse(init.body);
        if (liked !== server.liked) server.likes += liked ? 1 : -1;
        server.liked = liked;
        return { ...server };
      });
    });
    render(<App postId={7} initialLiked={false} initialLikes={12} />);
    await user.click(screen.getByRole("button", { name: "Like" }));
    answers[0].resolve(false);
    expect(await screen.findByRole("alert")).toHaveTextContent("Couldn't save your like.");
    expect(screen.getByText("12 likes")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Like" })).toHaveAttribute("aria-pressed", "false");
    
  6. rolls back when the request itself fails
    mockFetch(() => {
      throw new TypeError("Failed to fetch");
    });
    render(<App postId={7} initialLiked={true} initialLikes={20} />);
    await user.click(screen.getByRole("button", { name: "Like" }));
    expect(await screen.findByText("Couldn't save your like.")).toBeInTheDocument();
    expect(screen.getByText("20 likes")).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Like" })).toHaveAttribute("aria-pressed", "true");
    
  7. the next click hides the error
    const server = { liked: false, likes: 12 };
    const answers = [];
    const fetch = mockFetch((url, init) => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise.then((saved) => {
        if (!saved) return fail(500);
        const { liked } = JSON.parse(init.body);
        if (liked !== server.liked) server.likes += liked ? 1 : -1;
        server.liked = liked;
        return { ...server };
      });
    });
    render(<App postId={7} initialLiked={false} initialLikes={12} />);
    const button = screen.getByRole("button", { name: "Like" });
    await user.click(button);
    answers[0].resolve(false);
    await screen.findByText("Couldn't save your like.");
    await user.click(button);
    expect(screen.queryByText("Couldn't save your like.")).not.toBeInTheDocument();
    answers[1].resolve(true);
    await flush();
    expect(screen.getByText("13 likes")).toBeInTheDocument();
    
  8. rapid clicks each show at once and each send what's on screen
    const server = { liked: false, likes: 12 };
    const answers = [];
    const fetch = mockFetch((url, init) => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise.then((saved) => {
        if (!saved) return fail(500);
        const { liked } = JSON.parse(init.body);
        if (liked !== server.liked) server.likes += liked ? 1 : -1;
        server.liked = liked;
        return { ...server };
      });
    });
    render(<App postId={7} initialLiked={false} initialLikes={12} />);
    const button = screen.getByRole("button", { name: "Like" });
    await user.click(button);
    await user.click(button);
    expect(screen.getByText("12 likes")).toBeInTheDocument();
    await user.click(button);
    expect(button).toBeEnabled();
    expect(button).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByText("13 likes")).toBeInTheDocument();
    expect(fetch.calls.map((call) => JSON.parse(call.body).liked)).toEqual([true, false, true]);
    
  9. a failed click doesn't undo the clicks after it
    const server = { liked: false, likes: 12 };
    const answers = [];
    const fetch = mockFetch((url, init) => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise.then((saved) => {
        if (!saved) return fail(500);
        const { liked } = JSON.parse(init.body);
        if (liked !== server.liked) server.likes += liked ? 1 : -1;
        server.liked = liked;
        return { ...server };
      });
    });
    render(<App postId={7} initialLiked={false} initialLikes={12} />);
    const button = screen.getByRole("button", { name: "Like" });
    await user.click(button);
    await user.click(button);
    await user.click(button);
    answers[1].resolve(true);
    await flush();
    answers[2].resolve(true);
    await flush();
    answers[0].resolve(false);
    await flush();
    expect(screen.getByText("Couldn't save your like.")).toBeInTheDocument();
    expect(server).toEqual({ liked: true, likes: 13 });
    expect(button).toHaveAttribute("aria-pressed", "true");
    expect(screen.getByText("13 likes")).toBeInTheDocument();
    
  10. an answer that arrives while another click is pending doesn't count twice
    const server = { liked: false, likes: 12 };
    const answers = [];
    const fetch = mockFetch((url, init) => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise.then((saved) => {
        if (!saved) return fail(500);
        const { liked } = JSON.parse(init.body);
        if (liked !== server.liked) server.likes += liked ? 1 : -1;
        server.liked = liked;
        return { ...server };
      });
    });
    render(<App postId={7} initialLiked={false} initialLikes={12} />);
    const button = screen.getByRole("button", { name: "Like" });
    await user.click(button);
    await user.click(button);
    answers[0].resolve(true);
    await flush();
    expect(button).toHaveAttribute("aria-pressed", "false");
    expect(screen.getByText("12 likes")).toBeInTheDocument();
    answers[1].resolve(true);
    await flush();
    expect(button).toHaveAttribute("aria-pressed", "false");
    expect(screen.getByText("12 likes")).toBeInTheDocument();
    

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