JavaScript and React course Β· Module 15: Patterns and Architecture

Inversion of Control in React - Flexible component APIs

10 min read
In this lesson8

Commander Nova wants a mission card in three places: on the bridge with action buttons, in the archive without buttons but with a footer, and in the report with a link to the details. So the first version of the card gets more and more boolean flags and more and more conditions inside:

1// Every new screen adds a flag and another condition
2function MissionCard({ mission, showActions, showFooter, compact }) {
3  return (
4    <div className={compact ? 'card card-compact' : 'card'}>
5      <h3>{mission.name}</h3>
6      {showActions && <button>Cancel</button>}
7      {showFooter && <span>Priority: {mission.priority}/10</span>}
8    </div>
9  );
10}

The card decides everything by itself, so every idea from a new screen means changing its code. A month later there are a dozen flags and nobody dares to touch them. On a spaceship this problem was solved long ago: a module has a standard socket, and the engineer decides whether to plug a plasma engine or an ion drive into it. In React this principle is called Inversion of Control (IoC).

What is Inversion of Control?

Inversion of Control means that a component hands control over its behavior and appearance to the consumer, the component that renders it. Instead of predicting every case with a flag, it leaves places that the consumer fills in: props, slots, render props and its own state rules. You already know some of these tools from the lesson on composition. Now we will build a flexible API from them, and at the end we will arrange them from the least to the most control given away.

Slot Pattern - named slots

In the composition lesson the card had a title and children. Now it gets three named slots: header, actions and footer. Each of them is an ordinary prop in which you pass a ready-made JSX element:

1function SpaceCard({ header, footer, actions, children }) {
2  return (
3    <div className="space-card">
4      {header && <div className="card-header">{header}</div>}
5      <div className="card-body">{children}</div>
6      {actions && <div className="card-actions">{actions}</div>}
7      {footer && <div className="card-footer">{footer}</div>}
8    </div>
9  );
10}

The {header && ...} notation renders a slot's frame only when the consumer passed something into it, so a card without a footer does not leave an empty bar behind. children works exactly as before. This is how the rebuilt bridge MissionCard fills the slots:

1function MissionCard({ mission }) {
2  return (
3    <SpaceCard
4      header={<h3>{mission.name}</h3>}
5      actions={
6        <>
7          <button>Details</button>
8          <button>Cancel</button>
9        </>
10      }
11      footer={<span>Priority: {mission.priority}/10</span>}
12    >
13      <p>Distance: {mission.distance} ly</p>
14    </SpaceCard>
15  );
16}

One slot can take several elements: the <>...</> fragment packs both buttons into a single value. The archive will use the same card without actions, and SpaceCard will not get a single new flag.

Polymorphic component - the as prop

The action button and the link to the report should look the same, but in HTML they are different elements: a <button> performs an action, and an <a> takes you to an address. Instead of writing two components with the same styles, let the consumer choose the element with the as prop:

1function SpaceButton({ as: Component = 'button', children, ...props }) {
2  return <Component {...props}>{children}</Component>;
3}

The as: Component = 'button' notation reads the as prop, names it Component and gives it the default value 'button'. The capital letter is required: JSX treats lowercase names as HTML tags, so writing <as> would not work. The remaining props, such as href or onClick, land in props and are passed on unchanged. The full version adds a variant and computes the classes in a cls variable:

1function SpaceButton({ as: Component = 'button', variant = 'primary', children, ...props }) {
2  const cls = `space-btn btn-${variant}`;
3  return (
4    <Component className={cls} {...props}>
5      {children}
6    </Component>
7  );
8}

{...props} comes after className, so the consumer can override even the class if needed. In React 19 ref is an ordinary prop too, so it reaches the element along with the rest, without forwardRef. Now the usage. Link from the React Router library takes the address in the to prop, not href:

1function MissionToolbar({ onStart }) {
2  return (
3    <nav>
4      <SpaceButton onClick={onStart}>Launch</SpaceButton>
5      <SpaceButton as="a" href="/reports">Reports</SpaceButton>
6      <SpaceButton as={Link} to="/missions" variant="danger">Mission list</SpaceButton>
7    </nav>
8  );
9}

The first one renders a <button>, the second an <a href>, and the third the Link component, which renders a link itself. All three get their classes from one place, and SpaceButton does not know what it will become. Use as to choose the right semantics, not to get around them: <SpaceButton as="div"> looks like a button, but it does not get focus and does not respond to Enter.

Component injection - renderItem

The mission list on the bridge shows only names, while the missions screen shows full cards. If MissionList imported a specific card, it would be tied to one look. Instead, the consumer injects the way an item is rendered: the renderItem function, the render prop you know from the lesson on composition:

1function MissionList({ missions, renderItem, emptyState }) {
2  if (missions.length === 0) {
3    return emptyState || <p>No missions</p>;
4  }
5  return (
6    <ul className="mission-list">
7      {missions.map((mission, index) => (
8        <li key={mission.id}>{renderItem(mission, index)}</li>
9      ))}
10    </ul>
11  );
12}

The list still takes care of what is shared: the <ul> tag, the key and the empty state. The consumer decides what an item contains, and the second argument, index, lets it number the missions, for example. When the list is empty, MissionList shows the emptyState slot or a default text. Two screens, the same list:

1function BridgeScreen({ missions }) {
2  return (
3    <MissionList
4      missions={missions}
5      renderItem={(mission, index) => <span>{index + 1}. {mission.name}</span>}
6    />
7  );
8}
9
10function MissionsScreen({ missions }) {
11  return (
12    <MissionList
13      missions={missions}
14      renderItem={(mission) => <MissionCard mission={mission} />}
15      emptyState={<p>The fleet is waiting for orders.</p>}
16    />
17  );
18}

The MissionList code did not change by a single character, yet once it shows numbered names and once full cards with slots. The name component injection also covers passing a component itself: as={Link} from the previous section is exactly an injected component.

Props collection and prop getters

In the previous lesson getToggleProps() returned a ready-made set of attributes. This idea comes in two flavors. A props collection is a plain object of props that the consumer spreads on its element. A prop getter is a function that returns such an object, but first takes the consumer's props and merges them with its own. A simplified useToggle shows both flavors side by side:

1function useToggle(initialState = false) {
2  const [isOpen, setIsOpen] = useState(initialState);
3  const toggle = () => setIsOpen((prev) => !prev);
4
5  // props collection: a ready-made object
6  const toggleProps = { onClick: toggle, 'aria-expanded': isOpen };
7
8  // prop getter: a function that merges the consumer's props with ours
9  const getToggleProps = ({ onClick, ...props } = {}) => ({
10    ...props,
11    'aria-expanded': isOpen,
12    onClick: (event) => {
13      onClick?.(event);
14      toggle();
15    },
16  });
17
18  return { isOpen, toggleProps, getToggleProps };
19}

The difference shows when the consumer wants to add its own onClick, for example an entry in the ship's log. With <button {...toggleProps} onClick={log}> its function replaces ours, and the panel stops opening. The prop getter calls both: first the consumer's function, if one was given (hence onClick?.(event)), and then toggle. Other props, such as className, pass through unchanged:

1function MissionReport({ onLog }) {
2  const { isOpen, getToggleProps } = useToggle();
3
4  return (
5    <section>
6      <button {...getToggleProps({ onClick: () => onLog('Report toggled') })}>
7        Mission report
8      </button>
9      {isOpen && <p>All systems are working.</p>}
10    </section>
11  );
12}

Libraries such as Downshift use prop getters, and Kent C. Dodds popularized the names of both patterns. That is why I recommend returning prop getters instead of ready-made objects: they cost a few more lines, and the consumer cannot break the behavior or the accessibility by adding its own onClick.

State Reducer - the consumer changes the rules

State Reducer goes the furthest. The component has default state logic, but the consumer can swap the transition rules without copying the component's code. Take the ship's airlock. By default it reacts to two actions, OPEN and CLOSE, and they are described by a reducer, a function that computes the new state from the state and an action:

1function airlockReducer(isOpen, action) {
2  switch (action.type) {
3    case 'OPEN': return true;
4    case 'CLOSE': return false;
5    default: return isOpen;
6  }
7}

The hook takes the reducer in its options, and when the consumer does not pass one, it uses the default. useReducer manages the state, and dispatch sends an action to the reducer:

1function useAirlock({ reducer = airlockReducer } = {}) {
2  const [isOpen, dispatch] = useReducer(reducer, false);
3  const open = () => dispatch({ type: 'OPEN' });
4  const close = () => dispatch({ type: 'CLOSE' });
5  return { isOpen, open, close };
6}

The default value = {} lets you call useAirlock() without arguments. Now the consumer adds its own rule: until the ship has docked, the airlock will not open:

1function DockingBay({ docked }) {
2  const airlock = useAirlock({
3    reducer: (isOpen, action) => {
4      if (action.type === 'OPEN' && !docked) {
5        return isOpen; // in vacuum the airlock stays closed
6      }
7      return airlockReducer(isOpen, action);
8    },
9  });
10
11  return (
12    <button onClick={airlock.isOpen ? airlock.close : airlock.open}>
13      {airlock.isOpen ? 'Close airlock' : 'Open airlock'}
14    </button>
15  );
16}

The consumer intercepts just one action and hands the rest to the default airlockReducer, so useAirlock did not change at all. The reducer must stay a pure function: no window.confirm, no API requests and no mutating objects, because in development, under <StrictMode>, React calls it twice.

Which pattern to choose?

Each of these techniques answers a different need of the consumer:

SituationApproach
Same structure, different content of its partsSlot Pattern
Different HTML elements or router componentsPolymorphic component (as)
Different look of list itemsComponent injection (renderItem)
Ready-made attributes and handlers to spread on an elementProps collection, or better a prop getter
Different rules for changing stateState Reducer

Levels of control

The same techniques can be arranged by how much control you hand over to the consumer:

  1. Props: the least IoC, the component receives data and decides by itself how to display it.
  2. Slots and children: the consumer decides the content of selected places.
  3. Render props and component injection: the consumer decides how a whole item is rendered.
  4. State reducer: the most IoC, the consumer changes even the state logic.

The higher on this list, the more flexible the component, but also the harder it is to use and to document. Just like on the bridge: too few switches leave the pilot unable to react to a surprise, and too many slow them down during every maneuver. My advice: start with the lowest level that solves the problem, and climb higher only when the consumer really needs more. In the next lesson you will hand control over even further: the team will switch whole features on and off with flags, without deploying new code.

Remember: a component with inverted control is a socket on a ship that provides the connector, and the consumer decides what to plug into it.

Code for this lesson: App.jsx
1import React, { useState, useReducer } from 'react';
2
3// === SLOT PATTERN (named slots): SpaceCard ===
4function SpaceCard({ header, footer, actions, children }) {
5  return (
6    <div className="space-card">
7      {header && <div className="card-header">{header}</div>}
8      <div className="card-body">{children}</div>
9      {actions && <div className="card-actions">{actions}</div>}
10      {footer && <div className="card-footer">{footer}</div>}
11    </div>
12  );
13}
14
15// === POLYMORPHIC COMPONENT: SpaceButton with the "as" prop ===
16function SpaceButton({ as: Component = 'button', variant = 'primary', children, ...props }) {
17  const cls = `space-btn btn-${variant}`;
18  return (
19    <Component className={cls} {...props}>
20      {children}
21    </Component>
22  );
23}
24
25// === COMPONENT INJECTION: MissionList with renderItem and emptyState ===
26function MissionList({ missions, renderItem, emptyState }) {
27  if (missions.length === 0) {
28    return emptyState || <p className="empty">No missions</p>;
29  }
30  return (
31    <ul className="mission-list">
32      {missions.map((mission, index) => (
33        <li key={mission.id}>{renderItem(mission, index)}</li>
34      ))}
35    </ul>
36  );
37}
38
39// The mission card fills the SpaceCard slots
40function MissionCard({ mission, onCancel }) {
41  const [showStatus, setShowStatus] = useState(false);
42  return (
43    <SpaceCard
44      header={<h4>{mission.name}</h4>}
45      actions={
46        <>
47          <SpaceButton aria-expanded={showStatus} onClick={() => setShowStatus((s) => !s)}>Details</SpaceButton>
48          <SpaceButton variant="danger" onClick={onCancel}>Cancel</SpaceButton>
49        </>
50      }
51      footer={<span className="meta">Priority: {mission.priority}/10</span>}
52    >
53      <p>Distance: {mission.distance} ly</p>
54      {showStatus && <p>Status: {STATUS_LABELS[mission.status]}</p>}
55    </SpaceCard>
56  );
57}
58
59// === PROPS COLLECTION AND PROP GETTER: useToggle ===
60function useToggle(initialState = false) {
61  const [isOpen, setIsOpen] = useState(initialState);
62  const toggle = () => setIsOpen((prev) => !prev);
63
64  // props collection: a ready-made object
65  const toggleProps = { onClick: toggle, 'aria-expanded': isOpen };
66
67  // prop getter: merges the consumer's props with ours
68  const getToggleProps = ({ onClick, ...props } = {}) => ({
69    ...props,
70    'aria-expanded': isOpen,
71    onClick: (event) => {
72      onClick?.(event);
73      toggle();
74    },
75  });
76
77  return { isOpen, toggleProps, getToggleProps };
78}
79
80// === STATE REDUCER: an airlock with default rules ===
81function airlockReducer(isOpen, action) {
82  switch (action.type) {
83    case 'OPEN': return true;
84    case 'CLOSE': return false;
85    default: return isOpen;
86  }
87}
88
89function useAirlock({ reducer = airlockReducer } = {}) {
90  const [isOpen, dispatch] = useReducer(reducer, false);
91  const open = () => dispatch({ type: 'OPEN' });
92  const close = () => dispatch({ type: 'CLOSE' });
93  return { isOpen, open, close };
94}
95
96// === LABELS AND DATA ===
97// The data keeps status values (they also build the CSS classes), the screen shows readable labels
98const STATUS_LABELS = { active: 'In progress', completed: 'Completed' };
99
100const INITIAL_MISSIONS = [
101  { id: 1, name: 'Alpha Centauri Expedition', distance: '4.37', status: 'active', priority: 9 },
102  { id: 2, name: 'Sirius Patrol', distance: '8.6', status: 'completed', priority: 5 },
103  { id: 3, name: 'Tau Ceti Survey', distance: '11.9', status: 'active', priority: 8 },
104];
105
106// === MAIN COMPONENT ===
107export default function App() {
108  const [updatedAt, setUpdatedAt] = useState('now');
109  const [lastTag, setLastTag] = useState(null);
110  const [view, setView] = useState('compact');
111  const [missions, setMissions] = useState(INITIAL_MISSIONS);
112  const [notes, setNotes] = useState(0);
113  const [docked, setDocked] = useState(false);
114  const report = useToggle();
115  const airlock = useAirlock({
116    // consumer rule: without docking the OPEN action changes nothing
117    reducer: (isOpen, action) => {
118      if (action.type === 'OPEN' && !docked) return isOpen;
119      return airlockReducer(isOpen, action);
120    },
121  });
122
123  const showTag = (e) => {
124    e.preventDefault();
125    setLastTag(e.currentTarget.tagName.toLowerCase());
126  };
127  const addNote = () => setNotes((n) => n + 1);
128  const cancelMission = (id) => setMissions((prev) => prev.filter((m) => m.id !== id));
129
130  return (
131    <div className="app">
132      <h1>Inversion of Control demo</h1>
133      <p className="subtitle">Slots, the as prop, renderItem, a prop getter and a state reducer</p>
134
135      <section className="section">
136        <h3>Slots (SpaceCard)</h3>
137        <SpaceCard
138          header={<h4>Ship status</h4>}
139          actions={<SpaceButton onClick={() => setUpdatedAt(new Date().toLocaleTimeString('en-US'))}>Refresh</SpaceButton>}
140          footer={<span className="meta">Last update: {updatedAt}</span>}
141        >
142          <p>Fuel: 87% | Speed: 14,200 km/s</p>
143        </SpaceCard>
144        <SpaceCard header={<h4>Archive</h4>}>
145          <p>Without the actions and footer slots the card draws no empty bars.</p>
146        </SpaceCard>
147      </section>
148
149      <section className="section">
150        <h3>Polymorphic component (the as prop)</h3>
151        <div className="btn-row">
152          <SpaceButton onClick={showTag}>Button</SpaceButton>
153          <SpaceButton as="a" href="#reports" onClick={showTag}>Link</SpaceButton>
154          <SpaceButton as="a" href="#archive" variant="danger" onClick={showTag}>Link (danger)</SpaceButton>
155        </div>
156        <p className="meta">Clicked element: {lastTag ? '<' + lastTag + '>' : 'none yet'}</p>
157      </section>
158
159      <section className="section">
160        <h3>Component injection (renderItem)</h3>
161        <p className="hint">In the detailed view, Cancel removes a mission. Remove them all to see the emptyState slot.</p>
162        <div className="toggle-row">
163          <button className={'chip ' + (view === 'compact' ? 'active' : '')} aria-pressed={view === 'compact'} onClick={() => setView('compact')}>Compact</button>
164          <button className={'chip ' + (view === 'detailed' ? 'active' : '')} aria-pressed={view === 'detailed'} onClick={() => setView('detailed')}>Detailed</button>
165        </div>
166        <MissionList
167          missions={missions}
168          renderItem={view === 'compact'
169            ? (mission, index) => (
170                <div className="compact-item">
171                  <span>{index + 1}. {mission.name}</span>
172                  <span className={'badge badge-' + mission.status}>{STATUS_LABELS[mission.status]}</span>
173                </div>
174              )
175            : (mission) => <MissionCard mission={mission} onCancel={() => cancelMission(mission.id)} />
176          }
177          emptyState={
178            <div className="empty">
179              <p>The fleet is waiting for orders.</p>
180              <SpaceButton onClick={() => setMissions(INITIAL_MISSIONS)}>Restore missions</SpaceButton>
181            </div>
182          }
183        />
184      </section>
185
186      <section className="section">
187        <h3>Props collection vs prop getter (useToggle)</h3>
188        <p className="hint">Both buttons get a consumer onClick that adds a note. With the collection it replaces the hook's onClick, so the report does not toggle. The getter calls both.</p>
189        <div className="btn-row">
190          <button className="chip" {...report.toggleProps} onClick={addNote}>Collection + own onClick</button>
191          <button className="chip" {...report.getToggleProps({ onClick: addNote })}>Getter + own onClick</button>
192        </div>
193        <p className="meta">Consumer notes: {notes} | Report: {report.isOpen ? 'open' : 'closed'}</p>
194        {report.isOpen && <p className="report">Mission report: all systems are working.</p>}
195      </section>
196
197      <section className="section">
198        <h3>State Reducer (useAirlock)</h3>
199        <label className="check">
200          <input type="checkbox" checked={docked} onChange={(e) => setDocked(e.target.checked)} />
201          Ship docked
202        </label>
203        <div className="btn-row">
204          <SpaceButton onClick={airlock.open}>Open airlock</SpaceButton>
205          <SpaceButton variant="danger" onClick={airlock.close}>Close airlock</SpaceButton>
206        </div>
207        <p className="meta">Airlock: {airlock.isOpen ? 'open' : 'closed'}{docked ? '' : ' (without docking the consumer rule ignores OPEN)'}</p>
208      </section>
209    </div>
210  );
211}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does Inversion of Control (IoC) mean in the context of React components?

  2. 2. What does the 'as' prop enable in polymorphic components, e.g., <SpaceButton as="a" href="/link">?

Hands-on tasks in the game

  • Code editor

    Finish three components that hand control to the consumer. SpaceCard (Slot Pattern): ___BLANK1___ and ___BLANK2___ are the conditions that make <header> and <footer> appear only when the consumer passed the header or footer prop (just like actions already works). SpaceButton (polymorphic): ___BLANK3___ is the name we rename the as prop to so that we can use it in JSX as an element, and ___BLANK4___ passes the remaining props (onClick, href, to) to the element. MissionList (Component Injection): ___BLANK5___ is the element shown instead of an empty list (when it is not given, the default message stays), and ___BLANK6___ calls the renderItem function with the mission and its index.

  • Vertical ordering

    Arrange the levels of Inversion of Control in React from LEAST to MOST control given away:

  • Horizontal ordering

    Arrange the syntax for defining a polymorphic component with the 'as' prop:

Useful articles