Skip to content

Instantly share code, notes, and snippets.

@Evanion
Last active October 21, 2024 07:04
Show Gist options
  • Save Evanion/22f0741f1b3a5f3e5e005df2cea2fd8a to your computer and use it in GitHub Desktop.
Save Evanion/22f0741f1b3a5f3e5e005df2cea2fd8a to your computer and use it in GitHub Desktop.
Rex - Redux like state manager based on rxjs and immer

@evanion/rex

Rex is a Redux-like state management library that is reactive and immutable out of the box. It's just a tiny wrapper lib on top of RxJS and immer.

Creating a store

To create a new store you call the createStore function with a reducer and its initial state.

There is no need to supply a default case in your reducer, and the reducer shouldn't return anything.

import { ReducerAction, createStore } from "@evanion/rex";
import { Draft } from "immer";

interface UserState {
  data: User | null;
  loading: boolean;
  loaded: boolean;
  error?: string;
}

enum UserAction {
  FetchUserInit = "FETCH_USER_INIT",
  FetchUserSuccess = "FETCH_USER_SUCCESS",
  FetchUserError = "FETCH_USER_ERROR",
  ResetUser = "RESET_USER",
}

type UserStoreActions =
  | ReducerAction<UserAction.FetchUserInit, { id: string }>
  | ReducerAction<UserAction.FetchUserSuccess, User>
  | ReducerAction<UserAction.FetchUserError, Error>
  | ReducerAction<UserAction.ResetUser, null>;

const initialState = {
  data: null,
  loading: false,
  loaded: false,
};

function reducer(state: Draft<UserState>, action: UserStoreActions) {
  switch (action.type) {
    case UserAction.FetchUserInit:
      state.loading = true;
      break;

    case UserAction.FetchUserSuccess:
      state.data = action.payload;
      state.loading = false;
      state.loaded = true;
      delete state.error;
      break;

    case UserAction.FetchUserError:
      state.loading = false;
      state.error = action.payload;
      break;

    case UserAction.ResetUser:
      state.user = null;
      state.loading = false;
      state.loaded = false;
      delete state.error;
  }
}

export const userStore = createStore(reducer, initialState);

Consuming the store

To listen to updates to a store, you can to subscribe to it:

const onStoreChange = (state) => {
  console.log("State have changed", state);
};

const subscription = userStore.subscribe(onStoreChange);

// when you want to stop subscribing to the store, you call unsubscribe
subscription.unsubscribe();

Dispatching an action

To dispatch an action call the stores dispatch action:

userStore.dispatch(UserAction.ResetUser);

Listening to specific actions

You can easily listen for specific actions to perform other operations and then dispatch a new action with that result

const actionSub = userStore.on(UserAction.FetchUserInit, async (payload) =>
  fetch(`https://fakestoreapi.com/products/${payload.id}`)
    .then((res) => res.json())
    .then((res) => res.body)
    .then((usr) => userStore.dispatch(UserAction.FetchUserSuccess, usr))
);

// when you want to stop listening for the action, unsubscribe
actionSub.unsubscribe();

This way, you can listen to an action in one store and dispatch an action in another store based on that action and its payload.

const actionSub = userStore.on(UserAction.FetchUserInit, (payload) => {
  orderStore.dispatch(OrderAction.FetchUserOrdersInit, { userId: payload.id });
});

Using in react

Using the store in react is relatively simple:

const useUser = () => {
  const [userState, setUserState] = useState({});

  useEffect(() => {
    const sub = userStore.subscribe(setUserState);
    return () => sub.unsubscribe();
  });

  return userState;
};

It's even easier if you use the provided useStore hook:

function ListUsers(){
  const {state, dispatch} = useStore<UserState, UserActions>(userStore)
  const fetchUser = (id:string) => dispatch(USerAction.FetchUserInit, id);

  return (
    <div>
      <button onClick={fetchUser('5abd7c')}>Get User</button>
      <pre>
        {JSON.stringify(state, null, 2)}
      </pre>
    </div>
  )
}

Advanced

If you want to consume the state or action streams, their observables are directly available

userStore.state$.subscribe((state)=>console.log('state change', state))
userStore.action$.subscribe((action)=>console.log('action dispatched', action)).
/**
* Exception that indicates that a race condition have occured.
*/
export class RaceCondition extends Error {
constructor(message: string) {
super(message);
this.name = 'RaceCondition';
}
}
import { useEffect, useState } from 'react';
import { createStore } from './store';
import { ReducerAction } from './types';
type Store<
State extends Record<string, unknown>,
Action extends ReducerAction<string, unknown>
> = ReturnType<typeof createStore<State, Action>>;
/**
* Let's you consume the state in a store, and dispatch actions to it.
*/
export function useStore<
State extends Record<string, unknown>,
Actions extends ReducerAction<string, unknown>
>(store: Store<State, Actions>) {
const [state, set] = useState<State>(store.state$.value);
/**
* Dispatch an action to the store.
*/
function dispatch<
Action extends Actions['type'],
Payload extends Actions['payload']
>(type: Action, payload: Payload) {
store.dispatch(type, payload);
}
useEffect(() => {
const sub = store.subscribe(set);
return function cleanup() {
sub.unsubscribe();
};
}, [store]);
return { state, dispatch };
}
import { RaceCondition } from './exceptions';
import { ReducerAction } from './types';
import { Draft, produce } from 'immer';
import { BehaviorSubject, Subject, Subscription } from 'rxjs';
export function createStore<
State extends Record<string, unknown>,
Action extends ReducerAction<string, unknown>,
>(reducer: (state: Draft<State>, action: Action) => void, initialState: State) {
const dispatching$ = new BehaviorSubject(false);
const action$ = new BehaviorSubject({
type: 'INIT' as unknown as Action['type'],
payload: null as unknown as Action['payload'],
});
const state$ = new BehaviorSubject(initialState || ({} as State));
/**
* Dispatches a new action in the store.
* @param type - the action you want to dispatch
* @param payload - The payload expected with that action
* @throws {@link ./exceptions#RaceCondition}
* Can't dispatch while the store is already dispatching
*/
function dispatch<
ActionType extends Action['type'],
Payload extends Extract<Action, { type: ActionType }>['payload'],
>(type: ActionType, payload: Payload): void {
if (dispatching$.value)
throw new RaceCondition("Can't dispatch while store is dispatching");
dispatching$.next(true);
const newState = produce<State>(state$.value, (draft) =>
reducer(draft, { type, payload } as unknown as Action),
);
// If current and next states are the same, we don't update the state
if (state$.value !== newState) state$.next(newState);
action$.next({ type, payload });
dispatching$.next(false);
}
/**
* Subscribe to changes in the store.
* @param callback - Callback that gets called when the state changes in the store
* @returns a subsription handler that can be used to unsubscribe
* @throws {@link ./exceptions#RaceCondition}
* Can't subscribe while the store is dispatching.
*/
function subscribe(callback: (state: State) => void): Subscription {
if (dispatching$.value)
throw new RaceCondition("Can't subscribe while store is dispatching");
return state$.subscribe(callback);
}
/**
* Listens to specific actions in a store,
* and calls the callback when that action is called.
* Adding the payload to the callback.
* @param action - The action to listen for
* @param callback - The callback that should get called when the action happens
* @returns a subsription handler that can be used to unsubscribe
* @throws {@link ./exceptions#RaceCondition}
* Can't subscribe while the store is dispatching.
*/
function on<ActionType extends Action['type']>(
action: ActionType,
callback: (
payload: Extract<Action, { type: ActionType }>['payload'],
) => void | Promise<void> | Subject<unknown>,
): Subscription {
if (dispatching$.value)
throw new RaceCondition("Can't subscribe while store is dispatching");
return action$.subscribe((latest) => {
if (latest.type === action) return callback(latest.payload);
});
}
return {
initialState,
action$,
dispatching$,
state$,
dispatch,
subscribe,
on,
};
}
import { Draft } from 'immer';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ReducerAction<ActionType extends string | 'INIT', Payload = any> = {
type: ActionType;
payload: Payload;
};
export type State<S = Record<string, unknown>> = Draft<S>;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment