Jan 6, 2022
Have you ever needed to build a user interface in React that updates in real-time based on server events? We struggled to find a good pattern that made this easy in our app. After trying to do this using Redux, we eventually found a much better way. In this post I'll document our journey and our (open source!) solution.
A major goal of Shortwave is to provide a much more real-time email experience. Users should see new emails right away without needing to click to refresh, and triage actions taken on one device should update other devices (and tabs) immediately.
To accomplish this, our apps have a websocket connection that incrementally syncs down data from our backend. We store that data locally and merge it with local state based on user actions, so that we can compensate for latency and give users a responsive app even when their network isn't.
Our apps have client-side logic in TypeScript for managing this local state, including handling asynchronous server updates, user input, disk persistence, and other business logic. To make our user interface work, however, we need to get this state into our React components.
Our first attempt at doing this was to just expose the state as an Observable to get it into React. Let's take an example of our draft service - which has an interface like this:
interface DraftService {
watchDraft(draftId: DraftId): Observable<Draft>;
watchAllDrafts(): Observable<Record<DraftId, Draft>>;
}
Usage of our service looks something like this:
const DraftPreview: React.VFC<{draftId: DraftId}> = ({draftId}) => {
const service: DraftService = useDraftService();
const [draft, setDraft] = useState<Draft | null>(null);
useEffect(() => {
const observable = service.watchDraft(draftId);
const subscription = observable.subscribe(setDraft);
return () => subscription.unsubscribe();
}, [service, draftId]);
return draft === null ? 'Loading...' : `Draft: ${draft.subject}`;
};
This worked, but we quickly noticed a problem. The first render pass in React always resulted in displaying the loading state to the user. Even if we had a draft loaded in memory and we could display it instantly, we had to wait until the useEffect hook ran to update the state. For toy apps, this isn't noticeable because useEffect can run and React can rerender the component before the browser has the chance to paint. Our application was large enough that there was flickering of the loading state every time a component was mounted.
Our fix for flickering? Put the state into Redux! So now our app had a hook like this at the top of the component hierarchy:
function useSyncDraftsIntoRedux() {
const service = useDraftService();
const dispatch = useDispatch();
useEffect(() => {
const observable = service.watchAllDrafts();
const subscription = observable.subscribe(
(drafts) => dispatch(setDraftsAction(drafts))
);
return () => subscription.unsubscribe();
}, [service, dispatch]);
}
This lets us fix our component's flickering while simplifying it at the same time. Great!
const DraftPreview: React.VFC<{draftId: DraftId}> = ({draftId}) => {
const draft = useSelector(
(state: Store) => state.drafts.drafts[draftId]
);
return draft == null ? 'Loading...' : `Draft: ${draft.subject}`;
};
What's not to love? Well, a lot it turns out! First of all, it's not clear when to load data in this model. In our draft example we can start piping them all into Redux at app load time, but we can't do this with all of our data. We have to manually set up the sync into Redux anytime we display the data anywhere in the app - a cumbersome and bug-prone pattern. We also ran into performance issues early on and needed to optimize our selectors with tools like Reselect.
Additionally, we have all our state duplicated into two places - first the service and then the Redux store. Not only did this make it difficult to figure out where the source of truth for our state was, it also required a bunch of extra code. We needed to create reducers to handle the state and actions so we can wire up the service to the store. We also were doing this before Redux Toolkit was production ready, which just meant even more boilerplate code to write.
Overall our use of Redux felt like overkill and imposed a very rigid structure to our code - all we needed was a way to expose our application state to React!
We wanted a simpler solution. We liked the simplicity of the observables pattern, but observables are designed for streams of data and don't necessarily have a "current" value. We needed to synchronously access state for our first render, so what we wanted was a data structure that both holds a current value and has a notification mechanism for when it's updated. Enter Watchables - a small data structure we built for exactly this purpose - to expose a value into React. At its core, Watchables have a small API that looks something like:
/* A readonly value that can be watched. */
interface Watchable<T> {
/* If a watchable has a value or is empty (a loading state). */
hasValue(): boolean;
/* Access the current value. */
getValue(): T;
/*
* Watch for updates to the value.
* Will initially be fired with the current value if there is one.
*/
watch((value: T) => void): Unsubscribe;
};
type Unsubscribe = () => void;
/* A mutable watchable value. */
interface WatchableSubject<T> extends Watchable<T> {
update(value: T): void;
}
We can now update our component to look something like the following:
const DraftPreview: React.VFC<{draftId: DraftId}> = ({draftId}) => {
const service: DraftService = useDraftService();
const watchable = useMemo(
() => service.watchDraft(draftId),
[service, draftId]
);
const [draft, setDraft] = useState<Draft | null>(
watchable.getOrDefault(null)
);
useEffect(() => watchable.watch(setDraft), [watchable]);
return draft === null ? 'Loading...' : `Draft: ${draft.subject}`;
};
Watchables also have some other nice properties - they allow for an empty loading state, frequently updated values can be snapshotted, and you can do memoized state transformations. Combining these with a small set of hooks allowed us to simplify our component even more:
const DraftPreview: React.VFC<{draftId: DraftId}> = ({draftId}) => {
const service: DraftService = useDraftService();
const draft = useMemoizedWatchable(
() => service.watchDraft(draftId),
[service, draftId]
);
return draft === null ? 'Loading...' : `Draft: ${draft.subject}`;
};
We now use Watchables all over in our application, for loading and displaying messages, drafts, contact information, and online presence status. It's become a fundamental part of our application and has helped us to simplify our architecture and define clear boundaries between our business logic and user interface.
As part of this blog post, we've open sourced our implementation of Watchables along with a small set of hooks at github.com/npm install --save @shortwave/watchable
. More information can be found on GitHub. If you found this post interesting, check us out - we're hiring!
Get a roundup of the latest feature launches and exciting opportunities with Shortwave