Controlled Compound with State Reducer

Combine compounds with reducers for the ultimate balance of defaults and overrides.

Core Idea

  • Compound Components let multiple child components communicate through shared state from a parent.
  • State Reducer Pattern gives consumers the ability to control or override state transitions with their own reducer logic.

👉 Together, this pattern creates extensible UI libraries where consumers can customize both structure and behavior.

Basics & Need

Imagine you’re building a Dropdown or Accordion:

  • You want child components like DropdownButton, DropdownMenu, DropdownItem.
  • Internally, the parent manages open/close state.
  • But what if a consumer wants to add custom rules (e.g., prevent closing on a specific condition)?

That’s where the State Reducer comes in. It lets consumers inject logic to alter how state changes happen — without losing the reusability of compound components.

How to Achieve It

Step 1: Build Compound Component

const DropdownContext = React.createContext();

const Dropdown = ({ children, reducer }) => {
const [state, dispatch] = React.useReducer(reducer, { isOpen: false });

const toggle = () => dispatch({ type: "toggle" });

return (
<DropdownContext.Provider value={{ state, toggle }}>
<div className="dropdown">{children}</div>
</DropdownContext.Provider>
);
};

const DropdownButton = ({ children }) => {
const { toggle } = React.useContext(DropdownContext);
return <button onClick={toggle}>{children}</button>;
};

const DropdownMenu = ({ children }) => {
const { state } = React.useContext(DropdownContext);
return state.isOpen ? <ul>{children}</ul> : null;
};

Step 2: Add State Reducer Control

const dropdownReducer = (state, action) => {
switch (action.type) {
case "toggle":
return { ...state, isOpen: !state.isOpen };
default:
return state;
}
};

// Custom reducer by consumer
const customReducer = (state, action) => {
if (action.type === "toggle" && state.preventClose) {
return state; // prevent closing under certain condition
}
return dropdownReducer(state, action);
};

// Usage
<Dropdown reducer={customReducer}>
<DropdownButton>Options</DropdownButton>
<DropdownMenu>
<li>Profile</li>
<li>Settings</li>
</DropdownMenu>
</Dropdown>;

👉 Here the consumer overrides state transitions with customReducer.

Best Practices

  • Expose a default reducer but allow consumers to override.
  • Keep action types predictable and documented.
  • Provide compound child components for structure (Button, Menu, Item).
  • Ensure safe defaults (fallback to default reducer when none is provided).

Real-World Examples

  1. Dropdown / Menu Components → Custom rules for open/close (e.g., prevent closing while form input is active).
  2. Accordion / Tabs → Custom reducer to allow multiple tabs open or enforce only one.
  3. Stepper / Wizard → Consumers control how users move between steps (e.g., validation rules).

Advantages

  • Highly flexible and reusable across different scenarios.
  • Combines UI structure (compounds) with behavioral control (reducer).
  • Keeps component APIs clean while still allowing deep customization.

Disadvantages

  • Increases complexity for both implementer and consumer.
  • Requires clear documentation (action types, reducer contract).
  • Might be overkill for small/simple components.

Common Problems / Pitfalls

  • Consumers may misuse the reducer → unexpected states.
  • Exposing too many action types → API overload.
  • Debugging becomes harder when logic is partly inside consumer reducers.

Summary Recommendation

Use Controlled Compound with State Reducer when building UI libraries or complex reusable components that need both flexible structure and customizable behavior.
Best suited for Dropdowns, Accordions, Steppers, or Form Wizards, where consumers may want to override default rules.
Keep the API clean: provide sensible defaults, clear reducer contracts, and compound components.