Todos - Optimistic

What if we wanted to optimistically add/toggle our todos before the server API call returns?

Introduction

ConanJs allows you to add optimistic updates to your state. In order to achieve this, it provides State monitor and asyncMerge. As you will see, the monitor also allows us to cancel ongoing async calls, and update our local state accordingly.

Adding the state

We need to slightly modify our Todo definition to make it optimistically updatable. It just needs to hold a status per todo item:

export enum OptimisticStatus {
    SETTLED = 'SETTLED',
    IN_PROCESS = 'IN_PROCESS',
}

export interface OptimisticData<T> {
    data: T;
    status: OptimisticStatus;
    cancelCb: ICallback;
}

export interface OptimisticTodoListData {
    todos: OptimisticData<ToDo>[];
    appliedFilter: VisibilityFilters;
}

Now we will enrich our app with a new optimistically updatable state. For that in our Conan's DI definition file, we have to add:

export interface OptimisticApp extends App {
    optimisticTodoListState: ConanState<OptimisticTodoListData, TodoListActions>
}

export let diContext = DiContextFactory.createContext<OptimisticApp, InternalDependencies>({
        todoListState: TodoListAsyncStateFactory.create,
        optimisticTodoListState: OptimisticTodoListData$
    }, {
        todoListService: TodoListServiceImpl
    },
);

Implement optimistic updates

The key part here is how the optimisticTodoListState is created, as it is in fact a merged state of the state's monitor info and the state's data:

export function OptimisticTodoListData$(todoListState: TodoListState): ConanState<OptimisticTodoListData, TodoListActions> {
    return todoListState.asyncMerge<OptimisticTodoListData>(
        {
            appliedFilter: VisibilityFilters.SHOW_ALL,
            todos: []
        },
        (monitorInfo, data, current) => {
            if (monitorInfo.currentAction == null) {
                return current;
            }

            let asyncTodo: ToDo = monitorInfo.currentAction.payload [0];
            if (monitorInfo.status === MonitorStatus.ASYNC_CANCELLED) {
                return ({
                    appliedFilter: current.appliedFilter,
                    todos: current.todos.filter(it => it.data.id !== asyncTodo.id)
                })
            }

            if (monitorInfo.status !== MonitorStatus.ASYNC_START) {
                return current;
            }

            return monitorInfo.currentAction.name === 'addTodo' ? {
                appliedFilter: current.appliedFilter,
                todos: [...current.todos, {
                    status: OptimisticStatus.IN_PROCESS,
                    data: asyncTodo,
                    cancelCb: () => {
                        if (monitorInfo.currentAction == null) {
                            throw new Error(`unexpected error`);
                        }

                        monitorInfo.currentAction.asap.cancel()
                    }
                }]
            } : monitorInfo.currentAction.name === 'toggleTodo' ? {
                    appliedFilter: current.appliedFilter,
                    todos: current.todos.map(todo => todo.data.id !== asyncTodo.id ? todo : {
                        status: OptimisticStatus.IN_PROCESS,
                        data: {
                            ...asyncTodo
                        },
                        cancelCb: () => {
                            if (monitorInfo.currentAction == null) {
                                throw new Error(`unexpected error`);
                            }

                            monitorInfo.currentAction.asap.cancel()
                        }
                    })
                } :
                current
        },
        (data, monitorInfo, optimisticData) => ({
            appliedFilter: data.appliedFilter,
            todos: Lists.mergeCombine(
                data.todos,
                optimisticData.todos,
                (todo, optimisticTodo) => todo.id === optimisticTodo.data.id,
                (todo) => ({
                    status: OptimisticStatus.SETTLED,
                    data: todo,
                    cancelCb: () => {
                    }
                })
            )
        })
    )
}

Adding cancellation

As the app can now access a merged state that also contains the monitor actions, it can invoke the cancel action. For example, we have slightly modified the Todo react component, to include a loading... and a cancel button:

interface TodoProps {
    toggleCb: ICallback;
    completed: boolean;
    text: string;
    status: OptimisticStatus,
    cancelCb: ICallback,
    id: string
}

export class OptimisticTodo extends React.Component<TodoProps> {
    render() {
        return (
            <li
                key={this.props.id}
                style={{
                    textDecoration: this.props.completed ? 'line-through' : 'none'
                }}
            >
                {this.props.text} <button onClick={this.props.toggleCb}>toggle</button>{this.props.status === OptimisticStatus.IN_PROCESS &&
                    <>'  ...LOADING!...' <button onClick={this.props.cancelCb}>cancel</button></>
                }
            </li>
        );
    }
}

Getting the code

Please feel free to visit our examples at:

or the code sandbox below, for more details on this ConanJs feature.

Code sandbox

Last updated