And voilà, we have a beautiful counter app. You can find the full code for this demo in a gist.
As the app is fairly simple, so is our reducer. But even for bigger apps state management is rarely bound to the main thread in my experience. Everything we are doing can also be done in a worker as we are not using any main-thread-only API like the DOM. So let’s remove all of the Redux code from our main file and put it in a new file for our worker. Additionally, we are going to pull in Comlink.
Comlink is a library to make web workers enjoyable. Instead of wrangling postMessage()
, Comlink implements the (surprisingly old) concept of RPC with the help of proxies. Comlink will give you a proxy and that proxy will “record” any actions (like method invocations) performed on it. Comlink will send these records to the worker, replay them against the real object and send back the result. This way you can work on an object on the main thread even though the real object lives in a worker.
With this in mind, we can move store
to a worker and proxy it back to the main thread:
// worker.js
import { createStore } from "redux";
import { expose } from "comlink";
const reducer = (state = 0, { type }) => {
// ... same old ...
};
const store = createStore(reducer);
expose(store);
On the main thread, we’ll create a worker using this file and use Comlink to create the proxy:
// main.js
import { wrap } from "comlink";
const remoteStore = wrap(new Worker("./worker.js"));
const store = remoteStore;
ReactDOM.render(
<Provider store={store}> <CounterDemo /> <//>,
document.getElementById("root")
);
// ... same old ...
remoteStore
has all the methods and properties that the store
has, but everything is async. More concretely that means that remoteStore
’s interface looks like this:
interface RemoteStore {
dispatch(action): Promise<void>;
getState(): Promise<State>;
subscribe(listener: () => void): Promise<UnsubscribeFunc>;
}
The reason for this is the nature of RPC. Every method invocation is turned into a postMessage()
by Comlink and it has to wait for the worker to come back with a reply. This process is inherently asynchronous. The advantage is that we just moved all processing into the worker, away from the main thread. We can use the remoteStore
the same way we would store
. We just have to remember to use await
whenever we call a method.
As the interface shows, subscribe()
expects a callback as a parameter. But functions can’t be sent via postMessage()
, so this would throw. For this reason Comlink provides proxy()
. Wrapping a value in proxy()
will cause Comlink to not send the value itself but a proxy instead. So it’s like Comlink using itself.
Another problem is that getState()
is expected to return a value synchronously, but Comlink has made it asynchronous. To solve this we’ll have to get our hands dirty and keep a local copy of the most recent state value we have received.
Let’s put all these two fixes in a wrapper for remoteStore
:
export default async function remoteStoreWrapper(remoteStore) {
const subscribers = new Set();
let latestState = await remoteStore.getState();
remoteStore.subscribe(
proxy(async () => {
latestState = await remoteStore.getState();
subscribers.forEach(f => f());
})
);
return {
dispatch: action => remoteStore.dispatch(action),
getState: () => latestState,
subscribe(listener) {
subscribers.add(listener);
return () => subscribers.delete(listener);
}
};
}
Note: You might have noticed that I re-implemented
subscribe()
here rather than just callingremoteStore.subscribe()
. The reason is that there is a long-standing issue with Comlink: When one end of aMessageChannel
gets garbage collected, most browsers are not able to garbage collect the other end, permanently leaking memory. Considering thatproxy()
creates aMessageChannel
and thatsubscribe()
might get called quite a lot, I opted to re-implement the subscription mechanism to avoid building up leaked memory. In the future, WeakRefs will help Comlink address this problem.
In our main file, we have to use this wrapper to turn our RemoteStore
into something that is fully compatible to Store
:
- const store = remoteStore;
+ const store = await remoteStoreWrapper(remoteStore);
With all of that in place, we can run our app. Everything should look and behave the same, but Redux is now running off-main-thread.
You can find the full code in a gist.
Comlink can help you move logic to a worker without buying into a massive refactor. I did take some shortcuts here (like ignoring the return value of remoteStore.subscribe()
), but all-in-all this is a web app that makes good use of a worker. Not only is the business logic separated from the view, but the processing of state is not costing us any precious main thread budget. Additionally, moving your state management to a worker means that all the parsing for the worker’s dependencies is happening off-main-thread as well.
Note: It was pointed out to me on Twitter that by moving Redux to a worker every state change will cause the creation of a new copy due to structured cloning. This can be bad as it will cause React to rerender the entire app instead of just the elements whose state properties that have changed. While I didn’t solve this problem in this blog post, I did talk about a solution in my previous blog post in the “Patching” section.