Optimistic UI in Practice
A quick how-to on making your React app interactions feel faster today without a full re-write.
I think we can all agree that web performance is important. In most cases, you will just measure the load times (First Contentful Paint (FCP), Time to Interactive (TTI), etc.) and get your conclusions from there, but that's not the full user experience. Responsiveness of the app, after it has loaded, is also as important.
After you've taken all the well-defined measures (small bundle size, modern file formats, cache headers etc.) to make things fast, what else can be done? As we aren't machines (yet), it is actually how we perceive the performance that matters. Thus, we can trick our brains into thinking things are faster than they actually are.
Optimistic UI pattern is one of the tools to improve perceived performance. It helps you bridge the gap between predictable state changes that take time to execute (e.g., slow API requests). It's a powerful technique that has an immediate effect on the user's experience.
The Practice
Recently, we were building an app with, as usual, a pretty strict time-frame. We got it working fairly reliably and even load fast, but UI interactions after initial load were sluggish. It was because most of the state updates had to be saved via API calls immediately, this resulted in many spinners. Majority of the API actions very rather simple and the outcome was predictable. Thus, the Optimistic UI pattern was a good fit to practice perceived performance improvements.
For demo purpose, imagine a LanguageSelect
component that performs an async action when the language is changed. We're pretty confident that, upon clicking, the language will be changed. Though, we have to wait for our API request to resolve and update the Redux state language
field so that our component would re-render.
1import React from 'react';2import { useSelector, useDispatch } from 'react-redux';3import { changeLanguage } from '../actions';45const languages = [6 { value: 'en', label: '🇺🇸 English' },7 { value: 'de', label: '🇩🇪 Deutsch' },8 { value: 'lt', label: '🇱🇹 Lietuvių' },9];1011function LanguageSelect() {12 const language = useSelector((state) => state.language);13 const dispatch = useDispatch();1415 return (16 <select17 value={language}18 onChange={async ({ target: { value: newValue } }) => {19 await dispatch(changeLanguage(newValue));20 }}21 >22 {languages.map((language) => (23 <option key={language.value} value={language.value}>24 {language.label}25 </option>26 ))}27 </select>28 );29}
The state update delay can easily be mitigated with a couple useState
and useEffect
hooks. Just store the temporary value to the component state and don't forget to update it once the language
value gets resolved.
1import React, { useState, useEffect } from 'react';2import { useSelector, useDispatch } from 'react-redux';3import { changeLanguage } from '../actions';45const languages = [6 { value: 'en', label: '🇺🇸 English' },7 { value: 'de', label: '🇩🇪 Deutsch' },8 { value: 'lt', label: '🇱🇹 Lietuvių' },9];1011function LanguageSelect() {12 const language = useSelector((state) => state.language);13 const dispatch = useDispatch();14 const [value, setValue] = useState(language);15 useEffect(() => {16 setValue(language);17 }, [language]);1819 return (20 <select21 value={value}22 onChange={async ({ target: { value: newValue } }) => {23 setValue(newValue);24 await dispatch(changeLanguage(newValue));25 }}26 >27 {languages.map((language) => (28 <option key={language.value} value={language.value}>29 {language.label}30 </option>31 ))}32 </select>33 );34}
This approach seems pretty simple, and most of the time it gets the job done. But what if we use the language
field elsewhere? How would we share the temporary optimistic state outside LanguageSelect
? Like, for example, showing a user's language flag.
1import React from 'react';2import { useSelector } from 'react-redux';34function Header() {5 const value = useSelector((state) => state.language);6 return (7 <header>8 <img src="https://karolis.sh/static/logo.png" alt="logo" />9 <span>{{ en: '🇺🇸', de: '🇩🇪', lt: '🇱🇹' }[value]}</span>10 </header>11 );12}
To solve this issue, we could lift the state up to context or maybe make a separate Redux reducer to store optimistic values. Both approaches might work, but what if you'd want to modify optimistic values directly in async calls or don't even want to use a specific state manager? Everything can be developed, but all this sounds like a major feature... and the deadline is nearing. We need something simple 🤔... and there is!
I wrote a small utility library - use-optimistic-update
. It is lightweight, built on top of an event emitter and doesn't need Redux or even React Context integrations. Let's see how we could make the LanguageSelect
optimistic with minimal effort.
1import React from 'react';2import { useSelector, useDispatch } from 'react-redux';3import { useOptimisticUpdate } from 'use-optimistic-update';4import { changeLanguage } from '../actions';56const languages = [7 { value: 'en', label: '🇺🇸 English' },8 { value: 'de', label: '🇩🇪 Deutsch' },9 { value: 'lt', label: '🇱🇹 Lietuvių' },10];1112function LanguageSelect() {13 const language = useSelector((state) => state.language);14 const dispatch = useDispatch();15 const { value, onUpdate } = useOptimisticUpdate('LANGUAGE', language);1617 return (18 <select19 value={value}20 onChange={async ({ target: { value: newValue } }) => {21 await onUpdate(() => dispatch(changeLanguage(newValue)), newValue);22 }}23 >24 {languages.map((language) => (25 <option key={language.value} value={language.value}>26 {language.label}27 </option>28 ))}29 </select>30 );31}
Sharing the optimistic state with other components is also rather easy.
1import React from 'react';2import { useOptimisticState } from 'use-optimistic-update';34function Header() {5 const { value } = useOptimisticState('LANGUAGE');6 return (7 <header>8 <img src="https://karolis.sh/static/logo.png" alt="logo" />9 <span>{{ en: '🇺🇸', de: '🇩🇪', lt: '🇱🇹' }[value]}</span>10 </header>11 );12}
And that's it - the language change action, as a whole, became optimistic. The library also exposes APIs (used by the same React hooks) for direct state manipulations, so it should cover most of the edge cases when dealing with predictable updates.
Conclusion
The optimistic UI pattern is a simple, yet effective, way to overcome physical limitations and achieve better user experience. Your app logic might be complex and implementation of such pattern might seem daunting at first, but it does not have to be that way. Start by gradually converting each action and maybe give use-optimistic-update
a try. Good luck!
Software Engineer
I build stuff using JavaScript and share my findings from time to time. I hope you will find something useful here.