Today's web applications should have an appealing design, offer a sufficient amount of functionality and be user-friendly. Furthermore, the expectations for application performance has increased massively - no one wants use laggy applications these days. In addition to technical approaches, other approaches, such as optimistic UI, are often used to improve the user experience.
What is Optimistic UI?
Optimistic UI is a pattern that shows the user the final state without the actual operation being completed. This gives the feeling of a robust, modern and performant UI.
For example, a list of names to which you can add any number of names or remove existing names via a button. If you remove a name, it immediately disappears from the list, even though the api request sent to delete the name from the database has not yet completed. We are optimistic and assume that the operation will succeed. If the operation fails, which is the exception, we restore the previous state and notify the user that the operation failed.
What is Autosave?
As the expression itself already explains, with this UI pattern user input is automatically saved or serialized in the database. If the application is closed unexpectedly, the input is not lost. Thus, a user will search in vain for the save button. This may take some getting used to in a classic web form, but it is becoming a trend and is already used in many forms.
What is ngrx?
Ngrx is the standard implementation of Angular for Redux. The Redux pattern is based on the Flux pattern to manage the application state. And it is based on 3 fundamental principles:
- A global application state (single source of truth)
- The state is read-only (immutability)
- Changes of the state are made with pure functions
These pure functions, called reducers, are triggered by an action. Since reducers must never contain side-effects, ngrx has introduced effects to properly handle side-effects and deal with asynchronous data flow, such as API calls. And finally, selectors are used for obtaining slices of store state.
How to integrate optimistic UI with ngrx?
A simple approach is to trigger a specific action for each state of the optimistic UI pattern. The first state is always the optimistic state triggered by a user action. That is, the store is changed to the state as if the operation was successful. The second state is either the successful case or the exceptional case when the operation failed. If the operation was successful, e.g. the http post API request for our change operation responded with a http 200, nothing else needs to be done. Because we have already set our store to the correct state. Exceptions can be, for example, when a new record has been created and our http put API request responds with an updated record that contains a technical ID which we also want to update in our store.
export const initialState = {
entries: [],
};
export const myEntryStoreReducer = createReducer(
initialState,
on(addEntry, (state, {newEntry}) => ({
...state,
entries: [...state.entries, newEntry]
})),
on(addEntrySuccess, (state, {newEntry}) => ({
...state,
// replace entry with updated properties
// (e.g. technical id) if needed
entries: replaceEntry(state.entries, newEntry)
})),
on(addEntryFailed, (state, {newEntry}) => ({
...state,
// remove entry to restore prevous state
entries: removeEntry(state.entries, newEntry)
})),
)
If the operation failed, we need to trigger a failed action to instruct our reducer to restore the previous state.
addEntryEffect$ = createEffect(() => actions$.pipe(
ofType(MyEntryStoreActions.addEntry),
mergeMap((action) => {
return myEntryApi.addMyEntry(action.newEntry).pipe(
...
map(updatedEntryFromResponse => addEntrySuccess({newEntry: updatedEntryFromResponse})),
catchError(error => of(addEntryFailed({newEntry: action.newEntry, error: error})))
);
})
));
How to integrate autosave with ngrx?
In a simple approach we use reactive forms which exposes a valueChanges
observable. It will emit the current value for each user input in our form for which we will trigger an action to update our store. To make sure our input will be serialized, we define an effect for our action which will call our API to persist the current user input in our database.
formControl.valueChanges.pipe(
// do anything
).subscribe(value => store.dispatch(autoSaveAction(value))));
Common issues with Optimistic UI and Autosave
Data load
In a simple autosave approach where an api request is sent for each input change the data load can reach a critical range since we do not know when a user has finished their input. The simplest solution is to send a request after a blur event instead for each value change. From a UX perspective, this may be not an optimal solution, as saving your input only after leaving the input field is not intuitive for users. This can lead to data loss if the browser is closed without leaving the focus of the input field first.
Another approach is to drop events triggered by a valueChanges
observable via a debounce()
pipe so that far fewer actions are emitted. Or bundle the action events directly in your relevant effect via a debounce()
pipe. However, if your back-end system quickly reaches a critical range of data load even debouncing may not an optimal solution.
Simultaneous requests
A common issue is to deal with simultaneous auto save requests. If we want to create all autosave requests simultaneously we use the mergeMap()
operator in our effects. It does not cancel previous requests and handles api responses in the incoming order. If we are interested in a response, for example to update our store with relevant information, we need to make sure the current response does not overwrite our store with the response of our last request, since we do not know in which order the responses will return. Debouncing our request should ease the situation for most cases.
Another approach would be to handle autosave requests with the concatMap()
operator. It does not trigger another api request until the previous finished. This way we are sure the current response will not overwrite our store with outdated data. The downside, however, is that our api requests are not created simultaneously, which could impact performance from a UX perspective.
JSON list preserved
Autosave requires preserved JSON lists. It sounds obvious, but we had to make the experience that not all APIs follow the JSON specification:
An object is an unordered collection of zero or more name/value pairs, where a name is a string and a value is a string, number, boolean, null, object, or array.
An array is an ordered sequence of zero or more values.
In our case, for technical reasons, a back-end system had sorted lists deep in an object structure by certain attributes. Once an attribute of an object in the list changed, the list was completely resorted. This resulted in lists in the object of the response of a POST request being sorted completely differently than in the body of the request. It led to a strange behavior in the user interface. Therefore, the sorting of the lists should always be adapted to the corresponding backend system. If this is not possible, as in our case when the sorting in the database is based on attributes that the API consumer does not have access to, the back-end system must always ensure that the JSON lists are preserved.
Conclusion
The ngrx framework provides a suitable structure for the implementation of optimsitic UI and autosave. When working with simultaneous requests, difficulties can occur that can destroy the integrity of data if not handled properly. With autosave in particular, the data load increases dramatically, and the back-end systems behind the APIs must be able to handle this. And finally, it is also fundamental that a REST-API follows the JSON specification.
Top comments (0)