Truly Reactive Stores for React. Inspired by Svelte
npm i react-svelte-stores
I wanted a good set of primitives with which I could build custom state management solutions.
I wanted a "cleaner" API than React's Context.
Gateway drug to Svelte, or a way for people who already love Svelte to write Svelte-like code in React.
You can use react-svelte-stores to create a finite state machine component that can receive messages from other components. Let's implement a minimal audio player to demonstrate this pattern.
useEffect
Player.tsx
import React, { FC, useEffect, useRef } from "react";
import { writable, useStoreState } from "react-svelte-stores";
// discriminated union of possible states.
type State =
| { status: "loading" }
| { status: "playing"; time: number }
| { status: "paused"; time: number };
// discriminated union of possible actions
type Action =
| { type: "LOADED" }
| { type: "PLAY" }
| { type: "PAUSE" }
| { type: "UPDATE_TIME"; time: number };
const reducer = (state: State, action: Action): State => {
// state transitions based on state ("status") and event ("action")
switch (state.status) {
// when in the "loading" state, only react to "LOADED" action
case "loading":
switch (action.type) {
case "LOADED":
return {
...state,
status: "playing",
time: 0,
};
default:
return state;
}
// when in the "playing" state, react to "PAUSE" and "UPDATE_TIME" actions
case "playing":
switch (action.type) {
case "PAUSE":
return {
...state,
status: "paused",
};
case "UPDATE_TIME":
return {
...state,
time: action.time,
};
default:
return state;
}
// when in the "paused" state, only react to "PLAY" action
case "paused":
switch (action.type) {
case "PLAY":
return {
...state,
status: "playing",
};
default:
return state;
}
}
};
const createReducibleStore = (
initialState: State,
reducer: (state: State, action: Action) => State
) => {
const { subscribe, update } = writable(initialState);
return {
subscribe,
// react-svelte-store's update method takes a callback that receives the current state,
// and returns the next state.
// we can create a dispatch method by taking an action,
// then passing the current state and the action
// into the reducer function within the update function.
dispatch: (action: Action) => update((state) => reducer(state, action)),
};
};
const initialState: State = {
status: "loading",
};
const playerFSM = createReducibleStore(initialState, reducer);
const Player: FC = () => {
const playerState = useStoreState(playerFSM);
const audio = useRef<HTMLAudioElement>(null);
useEffect(() => {
// side effects on state transitions
if (playerState.status === "playing") {
audio.current?.play();
}
if (playerState.status === "paused") {
audio.current?.pause();
}
}, [playerState.status]);
return (
<div>
<audio
ref={audio}
src=""
onTimeUpdate={(e) =>
playerFSM.dispatch({
type: "UPDATE_TIME",
time: e.currentTarget.currentTime,
})
}
/>
{playerState.status !== "loading" && (
<p>Current Time: {playerState.time}</p>
)}
{(() => {
switch (playerState.status) {
case "loading":
return <p>loading...</p>;
case "playing":
return (
<button onClick={() => playerFSM.dispatch({ type: "PAUSE" })}>
pause
</button>
);
case "paused":
return (
<button onClick={() => playerFSM.dispatch({ type: "PLAY" })}>
play
</button>
);
}
})()}
</div>
);
};
playerFSM
and calling playerFSM.dispatch
!useSelectedStoreState
, which takes a selector function as its second argument.OtherComponent.tsx
const OtherComponent: FC = () => {
const playerStatus = useSelectedStoreState(
playerFSM,
(state) => state.status
);
switch (playerStatus) {
case "loading":
return null;
case "playing":
return (
<button onClick={() => playerFSM.dispatch({ type: "PAUSE" })}>
pause
</button>
);
case "paused":
return (
<button onClick={() => playerFSM.dispatch({ type: "PLAY" })}>
play
</button>
);
}
};
This approach makes it (nearly?) impossible to reach impossible states, while making cross-component communication clean and easy. You can even dispatch actions without subscribing to the FSM store. This style of reducer function, which considers the previous state as well as the action, was inspired by this David K. Piano tweet
useStoreState(store: IStore<T>): T
useSelectedStoreState(store: IStore<T>, selector: <T, R>(state: T) => R): R
writable(initialState: T): IWritableStore<T>
readable(initialState: T, setCallback?: ReadableSetCallback<T>): IReadableStore<T>
persisted(initialState: T, storeKey: string): IWritableStore<T>
persistedAsync(initialState: T, storeKey: string, AsyncStorage: AsyncStorageStatic): IWritableStore<T>
Custom stores must expose the subscribe function to be usable with hooks.