npm.io
1.0.1 • Published 2d ago

@frontman-ai/react-statestore

Licence
Apache-2.0
Version
1.0.1
Deps
3
Size
31 kB
Vulns
0
Weekly
0
Stars
616

@frontman-ai/react-statestore

ReScript state management for React with pure reducers and managed side effects.

Two tools that work well together (or standalone):

  • StateReducer -- Local component state (like useReducer with side effects)
  • StateStore -- Global state with concurrent-safe selectors (like Redux, but tiny)

Installation

npm install @frontman-ai/react-statestore

Add to your rescript.json:

{
  "dependencies": ["@frontman-ai/react-statestore"]
}

Requirements

  • ReScript 12+
  • React 19+
  • @rescript/react ^0.14.0

Quick Start

1. Define your types
// Counter__Types.res
type state = {count: int}
type action = Increment | Decrement | Reset
type effect = LogCount(int)
2. Implement the reducer
// Counter__Reducer.res
type state = Counter__Types.state
type action = Counter__Types.action
type effect = Counter__Types.effect

let name = "Counter"

let next = (state, action) => {
  switch action {
  | Increment =>
    StateReducer.update(
      {count: state.count + 1},
      ~sideEffect=LogCount(state.count + 1),
    )
  | Decrement => StateReducer.update({count: state.count - 1})
  | Reset => StateReducer.update({count: 0})
  }
}

let handleEffect = (effect, _state, _dispatch) => {
  switch effect {
  | LogCount(n) => Console.log(`Count is now: ${n->Int.toString}`)
  }
}
3. Create a global store (optional)
// Counter__Store.res
let store = StateStore.make(module(Counter__Reducer), {count: 0})
let dispatch = action => store->StateStore.dispatch(action)

module Selectors = {
  let count = (state: Counter__Types.state) => state.count
}
4. Use in components
// With global store
@react.component
let make = () => {
  let count = StateStore.useSelector(Counter__Store.store, Counter__Store.Selectors.count)

  <div>
    <p>{React.string(`Count: ${count->Int.toString}`)}</p>
    <button onClick={_ => Counter__Store.dispatch(Increment)}>
      {React.string("+")}
    </button>
  </div>
}
// With local state
@react.component
let make = () => {
  let (state, dispatch) = StateReducer.useReducer(module(Counter__Reducer), {count: 0})

  <div>
    <p>{React.string(`Count: ${state.count->Int.toString}`)}</p>
    <button onClick={_ => dispatch(Increment)}>
      {React.string("+")}
    </button>
  </div>
}

API Reference

StateReducer
module type Interface

The interface your reducer module must satisfy:

module type Interface = {
  type state
  type action
  type effect
  let name: string
  let next: (state, action) => (state, array<effect>)
  let handleEffect: (effect, state, action => unit) => unit
}
StateReducer.update(state, ~sideEffect=?, ~sideEffects=?)

Helper to build the (state, array<effect>) return value from next:

// No effects
StateReducer.update(newState)

// Single effect
StateReducer.update(newState, ~sideEffect=MyEffect)

// Multiple effects
StateReducer.update(newState, ~sideEffects=[Effect1, Effect2])
StateReducer.useReducer(module(Reducer), initialState)

React hook for local component state with managed side effects.

let (state, dispatch) = StateReducer.useReducer(module(MyReducer), initialState)
StateStore
StateStore.make(module(Reducer), initialState)

Create a global store instance:

let store = StateStore.make(module(MyReducer), {count: 0})
StateStore.dispatch(store, action)

Dispatch an action to update state and run effects:

store->StateStore.dispatch(Increment)
StateStore.getState(store)

Read current state outside of React:

let currentState = StateStore.getState(store)
StateStore.subscribe(store, callback)

Subscribe to state changes. Returns an unsubscribe function:

let unsubscribe = StateStore.subscribe(store, () => Console.log("state changed"))
StateStore.useSelector(store, selector, ~compare=?)

React hook that subscribes to a slice of state. Uses useSyncExternalStoreWithSelector for concurrent-mode safety. Components only re-render when the selected value changes.

let count = StateStore.useSelector(store, state => state.count)

Custom equality:

let items = StateStore.useSelector(
  ~compare=Some((a, b) => a.id == b.id),
  store,
  state => state.selectedItem,
)

Design Principles

The next function is pure -- it computes new state and declares effects as data, with no side effects. Side effects are values returned from the reducer rather than callbacks; they run after the state update, so reducers stay testable without mocking.

The global store uses React's useSyncExternalStoreWithSelector to prevent tearing in concurrent mode. Selectors use custom structural equality that skips deep object comparison (assumes immutable state) while still handling array comparison.

License

Apache-2.0

Keywords