Redux-toolkit: Support for optimistic updates

Created on 13 Apr 2020  路  6Comments  路  Source: reduxjs/redux-toolkit

I'm trying to figure out how to implement optimistic updates with createAsyncThunk and I came up with something like this:

export const updateTodo = createAsyncThunk<
  Todo,
  {
    id: Todo["id"];
    changes: Partial<Todo>;
  }
>("todos/update", async (args, { dispatch, getState, requestId }) => {
  const { id, changes } = args;

  const previousTodo = (getState().todos as TodosState).entities[id] as Todo;

  const begin = () => {
    const todo: Todo = {
      ...previousTodo,
      ...changes,
      updatedAt: new Date().toISOString()
    };

    dispatch(updateTodo.fulfilled(todo, requestId, args));
  };

  const commit = () => todosApi.update(id, changes);

  const rollback = () => {
    dispatch(updateTodo.fulfilled(previousTodo, requestId, args));
  };

  begin();

  try {
    return commit();
  } catch (error) {
    rollback();
    throw error;
  }
});

I'd like to share this piece of code because I believe that it could ba a good starting point for something like a generic helper for such actions.

Most helpful comment

Thanks for the suggestion! I believe RTK already supports optimistic updates. This is how you could implement the exact same feature without any changes to RTK:

type Todo = {
  id: number;
};

export const updateTodo = createAsyncThunk(
  'updateTodo',
  async (args: {
    todo: Todo;
    changes: Partial<Todo>;
    updatedAt: ReturnType<typeof Date.prototype.toISOString>;
  }) => {
    const { todo, changes } = args;
    todosApi.update(todo.id, changes);
  }
);

const slice = createSlice({
  name: 'todoList',
  // I'm ignoring createEntityAdapter here for simplicity
  initialState: { todos: {} as { [id: number]: Todo } },
  reducers: {},
  extraReducers: reducer =>
    reducer
      .addCase(updateTodo.pending, (state, action) => {
        const { todo, changes } = action.meta.arg;
        state.todos[todo.id] = {
          ...todo,
          ...changes,
        };
      })
      .addCase(updateTodo.rejected, (state, action) => {
        const { todo } = action.meta.arg;
        state.todos[todo.id] = todo;
      }),
});

This has a lot of benefits over the solution you proposed, including:

  1. Greater separation of concerns, since no component is responsible for both calling the API (aka dealing with side effects) and creating the Todo.
  2. In the same note, Thunks handle side-effects like API calls, while reducers handle business data logic.
  3. Code is more declarative. It's easier to see that "when updateTodo is pending, we do this. When updateTodo is reject, we do that".

All 6 comments

Redux Toolkit uses Immer internally for making changes to immutable state. Immer will track the changes made and expose patch objects, and inversePatch objects that can be applied to safely rollback a commit to the store. I found that using these is the easiest and safest way to implement rollback of optimistic updates.

However, there's currently a bug which prevents us from accessing the patches tracked by Immer, which I'm in the process of working on #509 to resolve this. But I can confirm that tracking the patches and applying them on error is a super easy way of automating this problem across all your reducers in just a few lines of code.

Thanks for the suggestion! I believe RTK already supports optimistic updates. This is how you could implement the exact same feature without any changes to RTK:

type Todo = {
  id: number;
};

export const updateTodo = createAsyncThunk(
  'updateTodo',
  async (args: {
    todo: Todo;
    changes: Partial<Todo>;
    updatedAt: ReturnType<typeof Date.prototype.toISOString>;
  }) => {
    const { todo, changes } = args;
    todosApi.update(todo.id, changes);
  }
);

const slice = createSlice({
  name: 'todoList',
  // I'm ignoring createEntityAdapter here for simplicity
  initialState: { todos: {} as { [id: number]: Todo } },
  reducers: {},
  extraReducers: reducer =>
    reducer
      .addCase(updateTodo.pending, (state, action) => {
        const { todo, changes } = action.meta.arg;
        state.todos[todo.id] = {
          ...todo,
          ...changes,
        };
      })
      .addCase(updateTodo.rejected, (state, action) => {
        const { todo } = action.meta.arg;
        state.todos[todo.id] = todo;
      }),
});

This has a lot of benefits over the solution you proposed, including:

  1. Greater separation of concerns, since no component is responsible for both calling the API (aka dealing with side effects) and creating the Todo.
  2. In the same note, Thunks handle side-effects like API calls, while reducers handle business data logic.
  3. Code is more declarative. It's easier to see that "when updateTodo is pending, we do this. When updateTodo is reject, we do that".

Redux Toolkit uses Immer internally for making changes to immutable state. Immer will track the changes made and expose patch objects, and inversePatch objects that can be applied to safely rollback a commit to the store. I found that using these is the easiest and safest way to implement rollback of optimistic updates.

However, there's currently a bug which prevents us from accessing the patches tracked by Immer, which I'm in the process of working on #509 to resolve this. But I can confirm that tracking the patches and applying them on error is a super easy way of automating this problem across all your reducers in just a few lines of code.

Hi Andrew, sorry to bother, do you have an example of how to apply a patch for the rejected reducer case?
I dont understand if i have to install immer, because it seems that RTK does not expose applyPatch. Also in the immer docs you provided it says that the app needs to call enablePatches()

Thanks!

@AndrewCraswell are patches now exposed? I want to apply optimistic UI in the way you've suggested but I can see now documentation about it. @arctouch-danielbastos's comment is very helpful but would lead to more duplicated code in my case

Thanks for the suggestion! I believe RTK already supports optimistic updates. This is how you could implement the exact same feature without any changes to RTK:

type Todo = {
  id: number;
};

export const updateTodo = createAsyncThunk(
  'updateTodo',
  async (args: {
    todo: Todo;
    changes: Partial<Todo>;
    updatedAt: ReturnType<typeof Date.prototype.toISOString>;
  }) => {
    const { todo, changes } = args;
    todosApi.update(todo.id, changes);
  }
);

const slice = createSlice({
  name: 'todoList',
  // I'm ignoring createEntityAdapter here for simplicity
  initialState: { todos: {} as { [id: number]: Todo } },
  reducers: {},
  extraReducers: reducer =>
    reducer
      .addCase(updateTodo.pending, (state, action) => {
        const { todo, changes } = action.meta.arg;
        state.todos[todo.id] = {
          ...todo,
          ...changes,
        };
      })
      .addCase(updateTodo.rejected, (state, action) => {
        const { todo } = action.meta.arg;
        state.todos[todo.id] = todo;
      }),
});

This has a lot of benefits over the solution you proposed, including:

  1. Greater separation of concerns, since no component is responsible for both calling the API (aka dealing with side effects) and creating the Todo.
  2. In the same note, Thunks handle side-effects like API calls, while reducers handle business data logic.
  3. Code is more declarative. It's easier to see that "when updateTodo is pending, we do this. When updateTodo is reject, we do that".

Thank you for your suggestion. However your example does not show who should be responsible for passing the current todo into the resulting updateTodo action. I believe it shouldn't be the code that is dispatching the action as it might pass a stale or otherwise incorrect current todo which would lead to the todo being reverted to an incorrect value in case of rejection. This kind of violates the single source of truth principle, doesn't it? The dispatching code should only need to know the todo's id and the new values in order to update it and not make any assumptions about the current state of the todo.

Seems like there aren't any concrete changes we need to make here. Also, the upcoming "RTK Query" API does have specific support for optimistic updates, and that will be added to RTK itself in the very near future.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

amankkg picture amankkg  路  4Comments

jamesopstad picture jamesopstad  路  4Comments

Darrekt picture Darrekt  路  3Comments

ouweiya picture ouweiya  路  3Comments

Izhaki picture Izhaki  路  3Comments