DEV Community

loading...
Cover image for No-library React store with useSelector(), @action, thunks and SSR

No-library React store with useSelector(), @action, thunks and SSR

Aleksei Berezkin
Fullstack dev: Java, JS, TS, React
Updated on ・5 min read

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();
Enter fullscreen mode Exit fullscreen mode

State.ts

export type State = {
  toDoList: ToDo[],
}

export type ToDo = {
  text: string,
  done: boolean,
}
Enter fullscreen mode Exit fullscreen mode

ToDoList.tsx

export function ToDoList() {
  const toDoList = useSelector(state => state.toDoList);

  return <div>
    {
      toDoList.map(toDo =>
        <div>
          {toDo.done ? '' : ''}
          {toDo.text}
        </div>
      )
    }
  </div>;
}
Enter fullscreen mode Exit fullscreen mode

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)

/*
 * 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;
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

Instantiating the store:

ToDoApp.tsx

export function ToDoApp() {
  const [store] = useState(new Store());

  return <Store.Context.Provider value={store}>
    <ToDoList/>
  </Store.Context.Provider>;
}
Enter fullscreen mode Exit fullscreen mode

Usage:

ToDoList.tsx

function ToDoList() {
  const toDoList = Store.useSelector(st => st.toDoList);
  // The rest code unchanged
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Thunks now also need a reference to the store:

function loadToDos(store: Store) {
  // Body unchanged
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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}`));
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

myGlobals.d.ts

Tell TypeScript there's a global var

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

export {}
Enter fullscreen mode Exit fullscreen mode

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/>;
}
Enter fullscreen mode Exit fullscreen mode

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

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;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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?

Discussion (0)