@frontman-ai/react-statestore — Elm-architecture state management for ReScript + React

We just open-sourced the state management library we built for Frontman (https://frontman.sh). It’s a small ReScript-native library that gives you two things:

  • StateReducer — local component state, like useReducer but with managed side effects
  • StateStore — global store with concurrent-safe selectors via useSyncExternalStoreWithSelector
    The core idea is Elm-style: your next function is pure and returns (state, array). Effects are values, not callbacks — they run after the state update, so your reducers are trivially testable.
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})
  }
}
7 Likes

Hey! Thank you for sharing, but I believe you’ve forgot to post the link to the library itself.

I’m a fan of the Elm pattern and use it as well. Here’s what I came to:

  // In a page component
  let (state, dispatch) = ReactX.useEffectReducer(
    ~reducer=Logic.execute,
    ~runEffect=(effect, dispatch) => Runtime.runEffect(effect, ~dispatch, ~onNavigate=navigate),
    ~initialState=State.make(~target=patternId == "new" ? Create : Edit(patternId)),
    ~noEffect=T.Effect.NoEffect,
  )

and here’s an example of a page goodness:

module Dialog = {
  type t = [#DeleteConfirm]
}

module Alert = {
  type t = [
    | #PatternLoadFailed
    | #PatternSaveFailed
    | #PatternDeleteFailed
    | #FileUploadFailed
    | #PreviewLoadFailed
    | #ThumbnailUploadFailed
  ]
}

module PreviewStage = {
  type t =
    | Idle
    | Loading(int) // revision number to handle stale responses
    | Ready({centerDrawing: Drawing.t, displayDrawing: Drawing.t})
    | Failed
}

module Effect = {
  type rec t =
    | NoEffect
    | FetchPattern(Uuid.t)
    | SavePattern({isNew: bool, pattern: Pattern.Entity.t})
    | DeletePattern(Uuid.t)
    | UploadFile({fileName: string, content: string})
    | UploadThumbnail({content: string, mimeType: string, revision: int})
    | LoadPreview({url: string, revision: int})
    | Navigate(string)
    | Batch(array<t>)
}

module Action = {
  type t =
    | PageMounted
    | PatternLoaded(Pattern.View.t)
    | LoadFailed(ApiError.t)
    | FormChanged(PatternForm__State.t)
    | FormInputChanged
    | ChangesDiscarded
    | SaveRequested
    | SaveSucceeded(Pattern.View.t)
    | SaveFailed(ApiError.t)
    | DialogOpened(Dialog.t)
    | DialogClosed
    | DeleteConfirmed
    | DeleteSucceeded
    | DeleteFailed(ApiError.t)
    | AlertDismissed
    | FileUploadStarted({fileName: string, content: string})
    | FileUploadSucceeded(string)
    | FileUploadFailed(ApiError.t)
    | FileRemoved
    | PreviewLoaded({centerDrawing: Drawing.t, revision: int})
    | PreviewFailed(int) // revision
    | ThumbnailUploadStarted({content: string, mimeType: string})
    | ThumbnailCaptured(string) // dataURL from snapshot
    | ThumbnailUploadSucceeded({url: string, revision: int})
    | ThumbnailUploadFailed({error: ApiError.t, revision: int})
    | ThumbnailRemoved
}

module type State = {
  type t
  type target = Create | Edit(Uuid.t)

  let make: (~target: target) => t

  // Accessors
  let target: t => target
  let pattern: t => AsyncData.t<Pattern.View.t>
  let submissionState: t => SubmissionState.t
  let formState: t => PatternForm__State.t
  let isFormDirty: t => bool
  let formRevision: t => int
  let currentDialog: t => option<Dialog.t>
  let alert: t => option<Alert.t>
  let previewStage: t => PreviewStage.t
  let thumbnailState: t => PatternThumbnail__State.t

  // Derived state
  let isPresentable: t => bool
  let isTransient: t => bool
  let canCaptureSnapshot: t => bool
  let centerAspectRatio: t => option<float>
}

…and the key functions interfaces:

// Logic
let execute: (State.t, T.Action.t) => (State.t, T.Effect.t)

// Runtime
let runEffect: (T.Effect.t, ~dispatch: T.Action.t => unit, ~onNavigate: string => unit) => unit

This is a very powerful pattern that covers pages of any complexity with only one React hook. Easily unit-testable, traceable, debugable. Love it. Thank you, Elm.

I’m curious how’s your interpretation differs in implementation :man_detective:

For those who might be interesting, here’s the hook implementation itself:

// Effect-as-data pattern hook (Elm Architecture)
type effectReducer<'state, 'action, 'effect> = ('state, 'action) => ('state, 'effect)

type effectInterpreter<'effect, 'action> = ('effect, 'action => unit) => unit

// Internal: state + last effect produced by reducer
type stateWithEffect<'state, 'effect> = {
  state: 'state,
  lastEffect: 'effect,
}

/**
 * useEffectReducer implements the Elm Architecture pattern for React.
 *
 * The reducer is a pure function that takes state and action, returns new state and an effect.
 * The effect interpreter runs side effects and dispatches new actions.
 *
 * @param reducer Pure function: (state, action) => (newState, effect)
 * @param runEffect Effect interpreter: (effect, dispatch) => unit
 * @param initialState Initial state value
 * @param noEffect Value representing "no effect" (used to skip effect execution)
 * @returns Tuple of (state, dispatch)
 */
let useEffectReducer = (
  ~reducer: effectReducer<'state, 'action, 'effect>,
  ~runEffect: effectInterpreter<'effect, 'action>,
  ~initialState: 'state,
  ~noEffect: 'effect,
  ~withDebugLogging: bool=false,
): ('state, 'action => unit) => {
  // Wrap reducer to store effect alongside state
  let wrappedReducer = (stateWithEffect, action) => {
    if withDebugLogging {
      Console.log2("[useEffectReducer] Running action", action)
    }

    let (newState, effect) = reducer(stateWithEffect.state, action)

    if withDebugLogging {
      Console.log2("[useEffectReducer] New state", newState)
      Console.log2("[useEffectReducer] Effect", effect)
    }

    {state: newState, lastEffect: effect}
  }

  let (stateWithEffect, dispatch) = React.useReducer(
    wrappedReducer,
    {state: initialState, lastEffect: noEffect},
  )

  // Run effect whenever it changes (not on every render)
  React.useEffect1(() => {
    if stateWithEffect.lastEffect !== noEffect {
      runEffect(stateWithEffect.lastEffect, dispatch)
    }
    noEffectCleanup
  }, [stateWithEffect.lastEffect])

  (stateWithEffect.state, dispatch)
}
2 Likes

having effects as values is very powerful for testing!

Please send the link to the lib @BlueHotDog!

1 Like

Found it here: frontman/libs/react-statestore at main · frontman-ai/frontman · GitHub

2 Likes