Aleksei Berezkin
Posted on June 18, 2021
Image: https://reactjs.org/
First, I'm not against Redux or MobX. These are great libs offering you much more than just getting and setting state. But if you need only, well, getting and setting state — you probably don't need either 😉
The objective
We are going to build fully functional global or scoped store with async functions (known as “thunks” in Redux world) and server side rendering.
How it looks like
Store.ts
class Store {
state: State = {
toDoList: [],
}
@action()
addItems(items: ToDo[]) {
this.state.toDoList =
[...this.state.toDoList, ...items];
}
@action()
setStatus(text: string, done: boolean) {
this.state.toDoList =
this.state.toDoList
.map(toDo =>
toDo.text === text
? {...toDo, done}
: toDo
);
}
}
export const store = new Store();
State.ts
export type State = {
toDoList: ToDo[],
}
export type ToDo = {
text: string,
done: boolean,
}
ToDoList.tsx
export function ToDoList() {
const toDoList = useSelector(state => state.toDoList);
return <div>
{
toDoList.map(toDo =>
<div>
{toDo.done ? '✅' : ''}
{toDo.text}
</div>
)
}
</div>;
}
Basic implementation
The idea is embarrassingly simple:
- There's a
listeners
set inStore.ts
containing callbacks taking State -
@action
decorator modifies Store methods so that they invoke all listeners after each state update, passing the current state -
useSelector(selector)
hook subscribes on state changes adding a listener to the set, and returns current state part selected by providedselector
Store.ts (continuation)
/*
* Callbacks taking State
*/
const listeners: Set<(st: State) => void> = new Set();
/*
* Replaces the original method with
* a function that invokes all listeners
* after original method finishes
*/
function action(): MethodDecorator {
return function(
targetProto,
methodName,
descriptor: TypedPropertyDescriptor<any>,
) {
const origMethod = descriptor.value;
descriptor.value = function(this: Store, ...args: any[]) {
origMethod.apply(this, args);
listeners.forEach(l => l(this.state));
}
}
}
/*
* Subscribes on state; re-runs
* on selected state change
*/
export function useSelector<T>(
selector: (st: State) => T,
): T {
const [state, setState] = useState(selector(store.state));
useEffect(() => {
const l = () => setState(selector(store.state));
listeners.add(l);
return () => void listeners.delete(l);
}, []);
return state;
}
And that's it! Your store is ready for use.
Thunks
You don't heed useDispatch()
. Just write a function you want:
import {store} from './Store'
async function loadToDos() {
try {
const r = await fetch('/toDos')
if (r.ok) {
store.addItems(await r.json() as ToDo[]);
} else {
// Handle error
}
} catch (e) {
// Handle error
}
}
Multiple stores
That's the case when React context may be utilized. For this we need to get rid of effectively “global” store, and move listeners to the Store class instead.
Store.ts
class State {
// State init unchanged
// ...
private listeners = new Set<(st: State) => void>();
// Action methods unchanged except
// decorator name: it's Store.action()
// ...
static action() {
// Only one line changes. This:
// listeners.forEach(l => l(state))
// To this:
this.listeners.forEach(l => l(state))
// ...
}
static Context = React.createContext<Store | null>(null);
static useSelector<T>(selector: (st: State) => T) {
const store = useContext(Store.Context)!;
// The rest unchanged
}
}
Instantiating the store:
ToDoApp.tsx
export function ToDoApp() {
const [store] = useState(new Store());
return <Store.Context.Provider value={store}>
<ToDoList/>
</Store.Context.Provider>;
}
Usage:
ToDoList.tsx
function ToDoList() {
const toDoList = Store.useSelector(st => st.toDoList);
// The rest code unchanged
// ...
}
Thunks now also need a reference to the store:
function loadToDos(store: Store) {
// Body unchanged
// ...
}
You may write some higher order function that pulls a context for you... If you wish so 🙂
Server side rendering
There's nothing special about it: you serialize a state a into a var, then initialize Store with it, and then hydrate:
serverApp.tsx
import {renderToString} from 'react-dom/server';
const port = 3000;
const app = express();
app.get('/', (req, res) => {
const state = {toDoList: loadFromDB()};
const store = new Store(state);
const appStr = appToString(store);
res.send(
`<!DOCTYPE html>
<html lang="en">
<title>Hello React</title>
<link href="main.css" rel="stylesheet"/>
<script>var INIT_STATE=${JSON.stringify(state)}</script>
<body>
<div id="app-root">${appStr}</div>
<script src="main.js" defer/>
</body>
</html>`
);
});
function loadFromDB() {
return [{text: 'Implement me 😉', done: false}];
}
function appToString(store: Store) {
return renderToString(
<Store.Context.Provider value={store}>
<ToDoList/>
</Store.Context.Provider>
);
}
app.use(express.static(path.resolve(__dirname, 'dist')))
app.listen(port, () => console.log(`Server is listening on port ${port}`));
index.tsx
const state = window.INIT_STATE!;
const store = new Store(state);
ReactDOM.hydrate(
<Store.Context.Provider value={store}>
<ToDoList/>
</Store.Context.Provider>,
document.getElementById('app-root')
);
delete window.INIT_STATE;
myGlobals.d.ts
Tell TypeScript there's a global var
declare global {
interface Window {
INIT_STATE?: State
}
}
export {}
Class components
useSelector
can be replaced with higher order component:
function withSelector<P, St>(
selector: (st: State) => St,
Component: new (props: P & {statePart: St}) => React.Component<P & {statePart: St}>,
) {
return class extends React.Component<P, {statePart: St}> {
componentDidMount() {
listeners.add(this.handleUpdate);
}
componentWillUnmount() {
listeners.delete(this.handleUpdate);
}
handleUpdate = () => {
this.setState({
statePart: selector(store.state),
});
}
render() {
return <Component
statePart={this.state.statePart}
{...this.props}
/>;
}
}
}
class ToDoList extends React.Component<{statePart: State['toDoList']}> {
render() {
return this.props.statePart.map(toDo =>
<div>
{toDo.done ? '✅' : ''}
{toDo.text}
</div>
);
}
}
const ConnectedToDoList = withSelector<{}, State['toDoList']>(
state => state.toDoList,
ToDoList,
)
function App() {
return <ConnectedToDoList/>;
}
That reminds connect
, mapStateToProps
and all that “beloved” things 😉 So let's resist the urge to rewrite Redux and stick to hooks.
Batching
Multiple state updates within one microtask are automatically batched by React under the following conditions:
- React 17: Updates are batched if they occur within a task handling a browser event, such as click, touch, or key typing.
- React 18: All updates are automatically batched.
In our case, “batching” means that setState()
calls for all listeners will take effect together in the next microtask:
// setState() will take effect in the next microtask
const l = () => setState(selector(store.state));
If you're using React 18, you don't need to worry about inconsistent intermediate states. However, it's worth noting that each execution of @action
triggers all listeners on this line:
// Executed after each `@action`
listeners.forEach(l => l(this.state));
In large applications, this could theoretically lead to a performance penalty from iterating over the same array multiple times. To avoid this, you may debounce this iteration with queueMicrotask
:
Store.ts
let microtaskPending = false;
function action(): MethodDecorator {
return function(
targetProto,
methodName,
descriptor: TypedPropertyDescriptor<any>,
) {
const origMethod = descriptor.value;
descriptor.value = function(this: Store, ...args: any[]) {
origMethod.apply(this, args);
if (!microtaskPending) {
queueMicrotask(() => {
listeners.forEach(l => l(this.state));
microtaskPending = false;
});
microtaskPending = true;
}
}
}
}
Without decorators
If you don't want to use nonstandard JS feature you may fire listeners explicitly:
Store.ts
class Store {
// State init unchanged
addItems(items: ToDo[]) {
// ... Unchanged
fireListeners(this.state);
}
setStatus(text: string, done: boolean) {
// ... Unchanged
fireListeners(this.state);
}
}
function fireListeners(state: State) {
listeners.forEach(l => l(state));
}
Mutating operations
Because there's no help from Immer or MobX observables you have to produce referentially different objects to trigger changes. But is it possible to have obj.x = 1
in the store? Yes... sometimes. If you always select primitive values, you can mutate objects:
ToDoItem.tsx
export function ToDoItem(p: {i: number}) {
const text = useSelector(state =>
state.toDoList[p.i].text
)
const done = useSelector(state =>
state.toDoList[p.i].done
)
return <div>
{done ? '✅' : ''}
{text}
</div>
}
This example will catch toDoItem.done = done
because the second selector will produce a different value.
It's possible to have also working Array.push()
. For this there we need “helper” primitive value which updates together with an array. This update will “piggyback” array update:
Store.ts
class Store {
state: State = {
toDoList: [],
toDoListVersion: 0,
}
@action()
addItems(items: ToDo[]) {
this.state.toDoList = this.state.push(...items);
this.state.toDoListVersion += 1;
}
// Rest unchanged
}
ToDoList.tsx
export function ToDoList() {
const toDoList = useSelector(state => state.toDoList);
// Result can be dropped
useSelector(state => state.toDoListVersion);
return <div>
{
toDoList.map(toDo =>
<div>
{toDo.done ? '✅' : ''}
{toDo.text}
</div>
)
}
</div>;
}
This looks like a sophisticated optimization. So, let's leave it for the case it's really needed 😉
Conclusion: what you get and what you lose
Your benefits are simple: you just throw away tens of kilobytes (minified) off your bundle. Of course this comes with a price:
- No more Redux Dev tools
- No custom Redux middleware like Saga
- No more observed fields
- No more help from Immer or observables
- Neither truly functional nor reactive style anymore
What is your choice?
Posted on June 18, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.