Mosaik

⚙️ 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.

v0.0.1