Skip to the code
cf.

Optimistic chat messages

The tea shop's support chat feels slow: nothing appears until the server answers. Build Chat, the default export, so a sent message shows up straight away with useOptimistic, and the saved message replaces it when the server answers.

Chat takes initialMessages (an array of { id, text }) and send(text), which returns a promise of the saved message, { id, text }, or rejects when it fails. Both have defaults in the starter: two sample messages, and a pretend server that answers after a second and fails every third message.

Show the messages in order in a list, one list item each. Below it, a form with a text box labelled Message (its name is text) and a Send button. Send the form with a form action. Trim the text, and don't send anything when it's empty.

While a message is on its way, its list item shows the text and Sending… (with the single … character). When send resolves, the saved message takes its place, so each message shows once. The box empties once the action finishes.

When send rejects, the message stays in the list with Not sent and a Retry button instead of Sending…. Retry sends the same text again and shows Sending… again while it waits; if it works, the saved message takes the failed one's place, and if it fails, it shows Not sent again.

Several messages can be on their way at once. However the answers arrive, a message must never show twice.

<Chat initialMessages={[{ id: 1, text: "Is the shop open?" }]} send={send} />
 
// Is the shop open?
// Tea at 4? Sending…
// And cake? Not sent [Retry]
// [Message: ______] [Send]

What the tests check

  1. shows the messages it starts with
    render(<App initialMessages={[{ id: 1, text: "Is the shop open?" }, { id: 2, text: "Yes, until four." }]} send={() => new Promise(() => {})} />);
    const items = screen.getAllByRole("listitem");
    expect(items).toHaveLength(2);
    expect(items[0]).toHaveTextContent("Is the shop open?");
    expect(items[1]).toHaveTextContent("Yes, until four.");
    
  2. shows a sent message at once, marked Sending…
    const answer = deferred();
    const send = spy(() => answer.promise);
    render(<App initialMessages={[{ id: 1, text: "Is the shop open?" }]} send={send} />);
    await user.type(screen.getByLabelText("Message"), "Tea at 4?");
    await user.click(screen.getByRole("button", { name: "Send" }));
    expect(send).toHaveBeenCalledWith("Tea at 4?");
    const items = screen.getAllByRole("listitem");
    expect(items).toHaveLength(2);
    expect(items[1]).toHaveTextContent("Tea at 4?");
    expect(items[1]).toHaveTextContent("Sending…");
    answer.resolve({ id: 2, text: "Tea at 4?" });
    await flush();
    
  3. swaps in the saved message when send answers
    const answer = deferred();
    render(<App initialMessages={[{ id: 1, text: "Is the shop open?" }]} send={() => answer.promise} />);
    await user.type(screen.getByLabelText("Message"), "Tea at 4?");
    await user.click(screen.getByRole("button", { name: "Send" }));
    answer.resolve({ id: 2, text: "Tea at 4?" });
    await waitFor(() => expect(screen.queryByText(/Sending…/)).not.toBeInTheDocument());
    const items = screen.getAllByRole("listitem");
    expect(items).toHaveLength(2);
    expect(items[1]).toHaveTextContent("Tea at 4?");
    
  4. empties the box once the message is sent
    const answer = deferred();
    render(<App initialMessages={[]} send={() => answer.promise} />);
    await user.type(screen.getByLabelText("Message"), "Tea at 4?");
    await user.click(screen.getByRole("button", { name: "Send" }));
    answer.resolve({ id: 2, text: "Tea at 4?" });
    await waitFor(() => expect(screen.getByLabelText("Message")).toHaveValue(""));
    
  5. doesn't send an empty message
    const send = spy(() => new Promise(() => {}));
    render(<App initialMessages={[{ id: 1, text: "Is the shop open?" }]} send={send} />);
    await user.type(screen.getByLabelText("Message"), "   ");
    await user.click(screen.getByRole("button", { name: "Send" }));
    await flush();
    expect(send).toHaveBeenCalledTimes(0);
    expect(screen.getAllByRole("listitem")).toHaveLength(1);
    
  6. a failed message shows Not sent and a Retry button
    const answers = [];
    render(<App initialMessages={[]} send={() => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise;
    }} />);
    await user.type(screen.getByLabelText("Message"), "Tea at 4?");
    await user.click(screen.getByRole("button", { name: "Send" }));
    answers[0].reject(new Error("offline"));
    expect(await screen.findByText(/Not sent/)).toBeInTheDocument();
    expect(screen.getAllByRole("listitem")).toHaveLength(1);
    const item = screen.getByRole("listitem");
    expect(item).toHaveTextContent("Tea at 4?");
    expect(item).not.toHaveTextContent("Sending…");
    expect(within(item).getByRole("button", { name: "Retry" })).toBeInTheDocument();
    
  7. Retry sends the text again and shows Sending… until it's saved
    const answers = [];
    const send = spy(() => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise;
    });
    render(<App initialMessages={[]} send={send} />);
    await user.type(screen.getByLabelText("Message"), "Tea at 4?");
    await user.click(screen.getByRole("button", { name: "Send" }));
    answers[0].reject(new Error("offline"));
    await user.click(await screen.findByRole("button", { name: "Retry" }));
    expect(send).toHaveBeenCalledTimes(2);
    expect(send.calls[1].args).toEqual(["Tea at 4?"]);
    const item = screen.getByRole("listitem");
    expect(item).toHaveTextContent("Tea at 4?");
    expect(item).toHaveTextContent("Sending…");
    expect(item).not.toHaveTextContent("Not sent");
    answers[1].resolve({ id: 9, text: "Tea at 4?" });
    await waitFor(() => expect(screen.queryByText(/Sending…/)).not.toBeInTheDocument());
    expect(screen.queryByText(/Not sent/)).not.toBeInTheDocument();
    expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
    expect(screen.getAllByRole("listitem")).toHaveLength(1);
    
  8. a retry that fails again shows Not sent again
    const answers = [];
    render(<App initialMessages={[]} send={() => {
      const answer = deferred();
      answers.push(answer);
      return answer.promise;
    }} />);
    await user.type(screen.getByLabelText("Message"), "Tea at 4?");
    await user.click(screen.getByRole("button", { name: "Send" }));
    answers[0].reject(new Error("offline"));
    await user.click(await screen.findByRole("button", { name: "Retry" }));
    answers[1].reject(new Error("still offline"));
    expect(await screen.findByText(/Not sent/)).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
    expect(screen.getAllByRole("listitem")).toHaveLength(1);
    
  9. two messages on their way at once each show once
    const answers = {};
    render(<App initialMessages={[]} send={(text) => {
      answers[text] = deferred();
      return answers[text].promise;
    }} />);
    const itemsWith = (text) => screen.getAllByRole("listitem").filter((item) => item.textContent.includes(text));
    const box = screen.getByLabelText("Message");
    await user.type(box, "Tea at 4?");
    await user.click(screen.getByRole("button", { name: "Send" }));
    await user.clear(box);
    await user.type(box, "And cake?");
    await user.click(screen.getByRole("button", { name: "Send" }));
    expect(itemsWith("Sending…")).toHaveLength(2);
    answers["Tea at 4?"].resolve({ id: 2, text: "Tea at 4?" });
    await flush();
    expect(itemsWith("Tea at 4?")).toHaveLength(1);
    answers["And cake?"].resolve({ id: 3, text: "And cake?" });
    await waitFor(() => expect(screen.queryByText(/Sending…/)).not.toBeInTheDocument());
    expect(itemsWith("Tea at 4?")).toHaveLength(1);
    expect(itemsWith("And cake?")).toHaveLength(1);
    expect(screen.getAllByRole("listitem")).toHaveLength(2);
    

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