No-library React store with useSelector(), @action, thunks and SSR
Tags: react, webdev, redux, mobx
Published: 2021-06-18 18:00:39

_Image: https://reactjs.org/_

First, I'm not against [Redux](https://redux.js.org/) or [MobX](https://mobx.js.org/). 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
```typescript
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
```typescript
export type State = {
  toDoList: ToDo[],
}

export type ToDo = {
  text: string,
  done: boolean,
}
```

#### ToDoList.tsx
```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:

1. There's a `listeners` set in `Store.ts` containing callbacks taking State
2. `@action` decorator modifies Store methods so that they invoke all listeners after each state update, passing the current state
3. `useSelector(selector)` hook subscribes on state changes adding a listener to the set, and returns current state part selected by provided `selector`

#### Store.ts (continuation)
```typescript
/*
 * 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:

```typescript
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
```typescript
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
```typescript
export function ToDoApp() {
  const [store] = useState(new Store());

  return <Store.Context.Provider value={store}>
    <ToDoList/>
  </Store.Context.Provider>;
}
```

Usage:

#### ToDoList.tsx
```typescript
function ToDoList() {
  const toDoList = Store.useSelector(st => st.toDoList);
  // The rest code unchanged
  // ...
}
```

Thunks now also need a reference to the store:

```typescript
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
```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
```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

```typescript
declare global {
  interface Window {
    INIT_STATE?: State
  }
}

export {}
```

## Class components
`useSelector` can be replaced with higher order component:

```tsx
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](https://github.com/reactwg/react-18/discussions/21) by React given the following conditions:

* React 17: updates are happening in a task that handles browser event like click, touch, key type
* React 18: all updates are automatically batched

It's possible that there's nothing to be concerned about 😉 However, if you think you need explicit batching, you may debounce updates with `queueMicrotask`:

#### Store.ts
```typescript
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
```typescript
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
```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
```typescript
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
```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](https://redux-saga.js.org/)
* No more [observed fields](https://mobx.js.org/observable-state.html)
* No more help from Immer or observables
* Neither truly functional nor reactive style anymore

What is your choice?