⚙️ State Management in Mosaik: Actions, Effects, and Predictable Transitions
Modern web apps often have to balance two demands:
- Complex state logic that stays predictable and testable
- Async workflows that talk to APIs, handle errors, retry, and react to real-world side effects
This is why time-tested state management patterns like Redux and Redux-Saga have been so popular for big apps:
- They enforce clear separation between pure state transitions and side effects.
- They make your state updates fully serializable — so debugging, logging, or replaying actions is trivial.
- They support advanced flows like “do X → call API → if success, dispatch Y → if failure, dispatch Z”.
🧩 Mosaik’s Take: Keep It Familiar, Keep It Modern
In Mosaik, we adopt the same core principles — but make them idiomatic for modern React:
- We use
useReducer
for predictable, testable state updates. - We handle effects with normal
useEffect
or async actions, without extra libraries. - We keep all state transitions pure, so your reducer can always be serialized, replayed, or tested in isolation.
🔄 How It Works
🪁 1️⃣ Pure Reducer
Your reducer is just a pure function:
type State = { sidebar: { collapsed: boolean } };
type Action = { type: 'COLLAPSE' | 'EXPAND' };
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'COLLAPSE':
return { ...state, sidebar: { collapsed: true } };
case 'EXPAND':
return { ...state, sidebar: { collapsed: false } };
default: return state;
}
};
No side effects, no async, no surprises — just pure state transitions.
⚡ 2️⃣ Effects Produce New Actions
When you need side effects — like an API call, localStorage update, or analytics —
the reducer stays pure. It only describes the side effect by adding an effects
array.
Here’s how it works in Mosaik, with a realistic blog post deletion flow:
// ----------------------------------------
// ✅ 1. Reducer: describe the effect
// ----------------------------------------
export const reducer = (state, action) => {
switch (action.type) {
case 'DELETE_BLOG_POST':
return {
...state,
deleting: true,
effects: [{ type: 'DELETE_BLOG_POST_EFFECT', payload: action.payload }]
};
case 'DELETE_BLOG_POST_SUCCESS':
return {
...state,
deleting: false,
posts: state.posts.filter(post => post.id !== action.payload.id)
};
case 'DELETE_BLOG_POST_FAILURE':
return {
...state,
deleting: false,
error: action.error
};
case '__CLEAR_EFFECTS__':
return {
...state,
effects: []
};
default:
return state;
}
};
// ----------------------------------------
// ✅ 2. The StateProvider runs the effect later
// ----------------------------------------
const runEffect = async (effect, dispatch) => {
switch (effect.type) {
case 'DELETE_BLOG_POST_EFFECT':
try {
await apiDeletePost(effect.payload.id);
dispatch({
type: 'DELETE_BLOG_POST_SUCCESS',
payload: { id: effect.payload.id }
});
} catch (err) {
dispatch({
type: 'DELETE_BLOG_POST_FAILURE',
error: err.message
});
}
break;
default:
break;
}
};
useEffect(() => {
if (state.effects?.length) {
state.effects.forEach((effect) => runEffect(effect, dispatch));
dispatch({ type: '__CLEAR_EFFECTS__' });
}
}, [state.effects]);
✅ 3️⃣ Predictable, Serializable State
Because Mosaik’s reducers stay pure:
- You can log every action.
- You can save state snapshots to debug hard problems.
- You can rehydrate the state on the client if you pre-render on the server.
- Your unit tests are trivial — test a reducer in isolation with
expect
.
🔌 How Is This Like Redux + Sagas?
Mosaik’s pattern is conceptually the same:
- Redux:
reducer(state, action) -> newState
- Saga: watches for actions, runs async effects, dispatches more actions
In Mosaik:
useReducer
is your pure store.useEffect
or custom hooks handle async flows.- You keep the benefit of predictable data flow, without a big runtime or complex config.
🧵 Why Not Just Redux?
Redux is powerful — but:
- It’s extra boilerplate when you already have
useReducer
. - Modern React’s Server Components prefer local, minimal state.
- Mosaik’s slots + themes naturally break big monolithic stores into smaller local states — so
useReducer
is a better fit.
🗂️ Practical Benefits
✅ Fully serializable transitions
✅ Easy to debug, replay, or test
✅ Clear separation of pure logic and side effects
✅ Compatible with React Server Components
✅ No heavy libraries — it’s all native hooks
✅ Flexible — you can add more advanced flows as needed (queues, retries, optimistic updates)
🔗 Example: Async Save
// ----------------------------------------
// ✅ State shape
// ----------------------------------------
type State = {
saving: boolean;
data: any;
error?: string;
effects?: Effect[]; // <-- only exists during a transition
};
// ----------------------------------------
// ✅ Actions
// ----------------------------------------
type Action =
| { type: "SUBMIT_SAVE"; payload: any }
| { type: "SAVE_SUCCESS"; payload: any }
| { type: "SAVE_FAILURE"; error: string };
// ----------------------------------------
// ✅ Reducer: always returns a new state with effects
// ----------------------------------------
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "SUBMIT_SAVE":
return {
...state,
saving: true,
effects: [{ type: "SAVE", payload: action.payload }]
};
case "SAVE_SUCCESS":
return {
...state,
saving: false,
data: action.payload
};
case "SAVE_FAILURE":
return {
...state,
saving: false,
error: action.error
};
default:
return state;
}
};
// ----------------------------------------
// ✅ Effect type
// ----------------------------------------
type Effect = {
type: "SAVE";
payload: any;
};
// ----------------------------------------
// ✅ Effect runner: runs in a useEffect in StateProvider
// ----------------------------------------
const runEffect = async (effect: Effect, dispatch: (a: Action) => void) => {
switch (effect.type) {
case "SAVE":
try {
const data = await apiSave(effect.payload);
dispatch({ type: "SAVE_SUCCESS", payload: data });
} catch (err) {
dispatch({ type: "SAVE_FAILURE", error: err.message });
}
break;
default:
break;
}
};
// ----------------------------------------
// ✅ StateProvider skeleton
// ----------------------------------------
export function StateProvider({ reducer, initialState, children }) {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
if (state.effects && state.effects.length > 0) {
state.effects.forEach((effect) => runEffect(effect, dispatch));
}
// IMPORTANT: clear effects to prevent re-run
if (state.effects) {
dispatch({ type: "__CLEAR_EFFECTS__" });
}
}, [state.effects]);
return (
<StateContext.Provider value={{ state, dispatch }}>
{children}
</StateContext.Provider>
);
}
// ----------------------------------------
// ✅ Reducer handles clearing effects too
// ----------------------------------------
export const reducer = (state: State, action: Action | { type: "__CLEAR_EFFECTS__" }): State => {
if (action.type === "__CLEAR_EFFECTS__") {
const { effects, ...rest } = state;
return rest; // strip effects
}
// ... your normal switch ...
};
📌 Zero Lock-In
If your project grows into something huge:
- You can still plug in Redux, Zustand, or even React Query for special use cases.
- Mosaik’s core stays the same: pure slots, pure state, flexible effects.
🚀 Summary
Mosaik’s state system:
- Keeps your UI predictable and fully testable.
- Handles async flows with clear action → effect → action chains.
- Uses modern React patterns instead of legacy tools.
- Makes it easy to scale from static pages to rich, interactive UIs.
- Is designed to run equally well on the client or the server — so you can safely serialize state, share actions, and hydrate on demand.
State that’s predictable, inspectable, and easy to reason about — the Mosaik way.