Code for breakfast.

Optimistic updates with SWR

Please note: This post assumes familiarity with Vercel's SWR library.

If you're not familiar with optimistic updates in interface design, UX Planet has a great introduction to optimistic UIs.

Alright, let's get rolling!

Here's our starting point - we have an API call, useSwr(TODO_LIST, fetcher), and a an event handler, addTodo, which makes a post call to our backend when a user creates a new todo.

import { mutate } from 'swr';

const TODO_LIST = '/api/todos';

const TodoListApp = ({ todos }) => {
  const { data } = useSwr(TODO_LIST, fetcher);

  const addTodo = async (todo) => {
    await postTodo(todo);
  };

  return <Todos todos={data.todos} onAddTodo={addTodo} />;
};

So how do we update the list of todos when it's abstracted away in SWR's hook?

Fortunately, SWR's API exposes a function called mutate, which gives you write access to the cache - we can use this to optimistically add the user's new todo to the existing list.

const addTodo = async (todo) => {
  mutate(
    // the first argument is the cache key
    // it should correspond to the `useSwr` call above
    TODO_LIST,
    // this is our updater function
    // it's like React's setState but for SWR's cache
    (todos) => [...todos, todo],
    // this last arg's name is `shouldRevalidate`
    // and it controls whether SWR should call the
    false,
  );

  await postTodo(todo);
};

There's just one problem - since the shouldRevalidate param above is false, SWR does not trigger a re-render in the corresponding useSwr hook. We can work around this by adding a piece of state to the component to track whether we're currently running an update operation or not, and toggle it appropriaately.

Here's the updated operation:

const TodoListApp = ({ todos }) => {
  const [isUpdating, setUpdating] = useState(false);
  const { data } = useSwr(TODO_LIST, fetcher);

  const addTodo = async (todo) => {
    mutate(TODO_LIST, (todos) => [...todos, todo], false);

    // trigger a re-render
    setUpdating(true);

    await postTodo(todo);

Problem solved!

There's still one issue - what if the postTodo fails? We'll have to roll back the value of TODO_LIST in SWR's cache.

  const addTodo = async (todo) => {
    mutate(TODO_LIST, (todos) => [...todos, todo], false);

    try {
      await postTodo(todo);

      // optional: calling mutate with just the cache key
      // marks that entry for revalidation,
      // so SWR will re-fetch todos from the backend
      // which will include the new todo!
      mutate(TODO_LIST);
    } catch (error) {
      handleError(error);
      // rollback in case of error
      mutate(
        TODO_LIST,
        (todos) => findAndRemoveElement(todos, todo),
        false,
      );
    }

Don't forget to toggle isUpdating after our operation.

    } finally {
      setUpdating(false);
    }

Let's put it all together. 🎉

import { mutate } from 'swr';

const TODO_LIST = '/api/todos';

const TodoListApp = ({ todos }) => {
  const [isUpdating, setUpdating] = useState(false);
  const { data } = useSwr(TODO_LIST, fetcher);

  const addTodo = async (todo) => {
    mutate(TODO_LIST, (todos) => [...todos, todo], false);

    // NOTE: mutating without revalidating
    // DOESN'T trigger a re-render,
    // you'll have to take care of that yourself
    setUpdating(true);

    try {
      await postTodo(todo);
      // optional: calling mutate with just the cache key
      // marks that entry for revalidation,
      // so SWR will re-fetch all todos
      mutate(TODO_LIST);
    } catch (error) {
      handleError(error);
      // rollback in case of error
      mutate(
        TODO_LIST,
        (todos) => findAndRemoveElement(todos, todo),
        false,
      );
    } finally {
      setUpdating(false);
    }
  };

  return <Todos todos={data.todos} onAddTodo={addTodo} />;
};

I could see this pattern abstracted out into a helper function - I'll leave the implementation up to you!

optimisticUpdate({
  cacheKey: TODO_LIST,
  operation: () => postTodo(todo),
  update: (todos) => [...todos, todo],
  rollback: (todos) => findAndRemoveElement(todos, todo),
});