A reference implementation of the SAM pattern for React 18 applications, using SAMProvider to wire SAM instances into the React component tree.
npm install && npm start
SAM and React have different mental models. SAM is a synchronous, deterministic state machine: every user event triggers a single action → model → state → render cycle with no interruptions. React 18 is concurrent: it can interrupt, pause, and replay renders.
The bridge between them is the only thing that needs care. The SAM pattern itself — actions, acceptors, NAPs — stays unchanged.
SAM's setRender normally takes a function that re-draws the UI. In React, we pass React's own setState as that function:
// SAMProvider.js (simplified)
const [state, setState] = useState(initialState)
useEffect(() => {
const instance = createInstance({ instanceName, clone: true })
const { addInitialState, addComponent, setRender } = api(instance)
addInitialState(savedState)
const { intents } = addComponent(components)
setRender(setState) // ← SAM drives React's state
setIntents(intents)
}, [])The result is that every SAM cycle calls setState(newModel), which triggers a React re-render with the full updated model. Components access the model and intents via SAMContext.
1. Define your SAM components and wrap with SAMProvider:
// Counter.js
import { SAMProvider, useLocalStorage } from '../lib/SAMProvider'
import CounterDisplay from './CounterDisplay'
function Counter({ counterName }) {
const initialState = { counter: 0 }
const components = {
actions: [
() => ({ incBy: 1 }),
['INCREMENT_BY_TWO', () => ({ incBy: 2 })]
],
acceptors: [
model => proposal => {
model.counter += proposal.incBy || 1
}
]
}
return (
<SAMProvider
initialState={initialState}
components={components}
instanceName={counterName}
persisted={useLocalStorage(initialState)}
>
<CounterDisplay />
</SAMProvider>
)
}2. Consume the context in child components:
// CounterDisplay.js
import { useContext } from 'react'
import { SAMContext, useInitializedContext } from '../lib/SAMProvider'
function CounterDisplay() {
const { isLoading, intents, state } = useInitializedContext(useContext(SAMContext))
if (isLoading) return <div>Loading…</div>
const [incrementByOne, incrementByTwo] = intents
return (
<div>
<p>Count: {state.counter}</p>
<button onClick={() => incrementByOne()}>+1</button>
<button onClick={() => incrementByTwo()}>+2</button>
</div>
)
}Each SAMProvider with a unique instanceName runs its own isolated SAM loop. Instances do not share state:
// App.js
function App() {
return (
<>
<Counter counterName="counter-1" />
<Counter counterName="counter-2" />
</>
)
}Pass a storage adapter as the persisted prop. The built-in useLocalStorage adapter saves to localStorage on unmount and restores on mount:
persisted={useLocalStorage(initialState)}The storage key is __SAMState_{instanceName}. You can implement any adapter that exposes { getState(instanceName), setState(state, instanceName) }.
| Prop | Type | Required | Description |
|---|---|---|---|
instanceName |
string |
No | Unique name for this SAM instance. Default: '__main' |
initialState |
object |
Yes | Starting model values |
components |
object |
Yes | { actions, acceptors, reactors, naps } — standard sam-pattern component definition |
persisted |
object |
No | Storage adapter { getState, setState } |
children |
ReactNode |
Yes | Components that consume SAMContext |
Every SAM cycle replaces the entire model, so all SAMContext consumers re-render together. For small components this is fine. For large apps with many consumers, add selectors or split into multiple providers with narrower state.
React 18 StrictMode intentionally mounts → unmounts → remounts components in development. This causes useEffect to run twice. The provider handles this by tracking the instance in a module-level registry and cleaning up on unmount. In production this doesn't happen.
The cleanest integration for concurrent-mode React is to keep the SAM model outside React state entirely and use useSyncExternalStore to subscribe components:
// The SAM model lives outside React
let snapshot = initialState
const listeners = new Set()
setRender(newModel => {
snapshot = newModel
listeners.forEach(l => l())
})
// In any component:
const state = useSyncExternalStore(
listener => { listeners.add(listener); return () => listeners.delete(listener) },
() => snapshot
)This approach is tearing-free, works correctly with Suspense and transitions, and avoids the StrictMode double-invoke issue. It is the recommended direction if this provider is extended for production use.
User event
│
▼
Action Transforms raw input into a proposal
│
▼
Model Acceptors decide whether to apply the proposal
│
▼
State Pure function: computes state representation from model
│
▼
Render Updates the UI with the new state representation
│
▼
NAP Next-Action Predicate: triggers automatic follow-up actions
See sam.js.org and sam-lib for the full pattern documentation.