Composing Views

Published:

We have shared behavior through custom Hooks, passed output through render props, and wrapped component types with higher-order components. Each puts a different boundary around behavior and output.

Now suppose that we have a small view of a person and want to reuse it in several places. One page has the name directly. Another has it inside user. A third needs the same view inside a section, followed by a count. These are changes to input and output, not necessarily new stateful behavior.

The next hole is:

how can we build a view from smaller functions that adapt its input and combine its output?

A function waiting for props #

Start with a small ordinary function that makes a React element:

const greeting = ({ name }) => <h2>Hello, {name}</h2>;

greeting({ name: 'Ada' }); // describes <h2>Hello, Ada</h2>

We will call this a view function. It is a lowercase, Hook-free helper, not a component invoked by React. It receives an object and returns a React node: an element in this example. React nodes can also include text, null, and other renderable values.

An element describes what React should render. It is not a DOM node and making one does not immediately render it. A component can use the helper's result in its own output:

function Page({ name }) {
  return <main>{greeting({ name })}</main>;
}

Put the component in the returned JSX:

const profileView = props => <Page {...props} />;

profileView(props) calls an ordinary helper. React later calls Page when it renders the element. Do not write Page(props) in the helper: React needs to control component calls and their identity in the tree.[1] The helper and the transformation callbacks we compose with it must be pure and must not call Hooks. A component represented by their returned JSX can use Hooks normally.[2]

Adapt the input #

The greeting expects { name }, but a page holds { user: { displayName } }. Calling greeting(pageProps) leaves name undefined. We could rewrite the greeting, but the original input is still useful elsewhere.

Instead, adapt the props before calling it:

const fromUser = ({ user }) => ({ name: user.displayName });
const userGreeting = props => greeting(fromUser(props));

userGreeting({ user: { displayName: 'Ada' } });

The data flows from right to left in greeting(fromUser(props)): first the adapter, then the existing view. The adapter accepts the new input and produces the input the old view expects. Repeating this shape suggests a named operation:

const contramap = (adapt, view) => input => view(adapt(input));
const userGreeting = contramap(fromUser, greeting);

The name contramap describes the direction of the adapter relative to the view: it maps a new input back to the input already required. No React component or DOM wrapper is created by this operation.

Transform the output #

Another page wants the greeting inside a labelled section. The input has not changed, so adapt the result instead:

const inSection = node => <section aria-label="Greeting">{node}</section>;
const sectionGreeting = props => inSection(greeting(props));

Again there is a repeated function shape. This time the transformation follows the view:

const map = (transform, view) => input => transform(view(input));
const sectionGreeting = map(inSection, greeting);

map is ordinary function composition applied to the output. contramap composes on the input side. Neither operation runs the view when it is defined; the resulting function waits for its input.

We can use both with the same greeting:

const pageGreeting = map(inSection, contramap(fromUser, greeting));

fromUser makes the expected props, greeting produces the node, and inSection wraps it. The section here does add a DOM element; the composition function itself does not.

Put two views side by side #

Now a page needs the greeting and the unread count. Both views can read the same page input:

const unreadView = ({ unread }) => <p>{unread} unread messages</p>;

const pageView = props => (
  <>
    {pageGreeting(props)}
    {unreadView(props)}
  </>
);

The Fragment groups the results without adding a DOM wrapper. Extract the common shape as concat:

const concat = (first, second) => input => (
  <>
    {first(input)}
    {second(input)}
  </>
);

const pageView = concat(pageGreeting, unreadView);

Both functions receive the same input. Their outputs appear in order, first then second. concat combines descriptions; it does not merge props or share Hook state between the components those descriptions may contain.[3]

Keep the operations with the view #

The standalone functions already work. A small box can make a chain easier to read and keep the operation names beside the view:

const view = run => ({
  fold: run,
  contramap: adapt => view(input => run(adapt(input))),
  map: transform => view(input => transform(run(input))),
  concat: other => view(input => (
    <>
      {run(input)}
      {other.fold(input)}
    </>
  )),
});

view accepts an ordinary function and returns an object. Each operation creates another box around a function; fold exposes that function so a caller can supply input. The names do not make the box a React component, and none of these operations invokes a component function directly.

Here is the complete example with a real stateful component inside the composed result:

const { useState } = React;

const view = run => ({
  fold: run,
  contramap: adapt => view(input => run(adapt(input))),
  map: transform => view(input => transform(run(input))),
  concat: other => view(input => (
    <>
      {run(input)}
      {other.fold(input)}
    </>
  )),
});

function Greeting({ name }) {
  const [open, setOpen] = useState(true);

  return (
    <div>
      <button aria-expanded={open} onClick={() => setOpen(value => !value)}>
        {open ? 'Hide' : 'Show'} greeting
      </button>
      {open && <h2>Hello, {name}</h2>}
    </div>
  );
}

const greeting = view(props => <Greeting {...props} />);
const unread = view(({ unread }) => <p>{unread} unread messages</p>);

const pageView = greeting
  .contramap(({ user }) => ({ name: user.displayName }))
  .map(node => <section aria-label="Greeting">{node}</section>)
  .concat(unread);

function App() {
  const [unreadCount, setUnreadCount] = useState(2);
  const props = { user: { displayName: 'Ada' }, unread: unreadCount };

  return (
    <div style={{ display: 'grid', gap: '1rem', justifyItems: 'start' }}>
      <button onClick={() => setUnreadCount(count => count + 1)}>
        Add unread message
      </button>
      {pageView.fold(props)}
    </div>
  );
}

root.render(<App />);

Waiting to run

Not run yet.

Hide the greeting, then add an unread message. The count changes while the greeting stays hidden. The composition produces a new description as App renders again, but <Greeting /> remains the same component type at the same position, so React preserves its state. The state belongs to that component instance, not to the box.[4]

The chain also exposes the contracts. greeting needs { name }, the adapter accepts an object with user.displayName, and unread needs unread. Their combination needs an input satisfying both branches. A type system could check those contracts; this JavaScript implementation leaves them to the author and caller.

What structure did we find? #

The functor article studies mappings that preserve identity and composition. Our output operation has that shape when we look at functions with a fixed input and allow their output type to vary. If g and f are pure output transformations:

map(identity, view)              behaves like view
map(f, map(g, view))             behaves like map(x => f(g(x)), view)

Here “behaves like” means applying the resulting functions to the same input produces equivalent results under the chosen equality. It does not mean that separately allocated JavaScript functions or React elements are reference-equal. The concrete box above keeps React nodes as its intended output, so its demonstrated map only accepts transformations from nodes back to nodes; a method named map alone does not prove a full functor over arbitrary output types.

There is also a second direction. Consider the more general function shape:

View<A, B> = A → B

contramap: (C → A) → View<A, B> → View<C, B>
map:       (B → D) → View<A, B> → View<A, D>

contramap composes an input transformation before the function; map composes an output transformation after it. Together those directions are the function profunctor: contravariant in the input and covariant in the output.[6] The React view is a useful instance of that shape, with props on the input side and a React node on the output side. The contravariant functor and function-space discussions develop the directions in more detail.

Input composition has its own laws. With g converting C to A and f converting D to C, applying contramap(g) and then contramap(f) adapts a D through f and then g. An identity adapter changes nothing. A pure input adapter and a pure output transformation also compose independently: both orders yield input => post(run(pre(input))) under the same result equivalence.

concat suggests another connection. If we compare only the ordered static output and ignore the grouping made by unkeyed Fragments, then grouping three outputs either way gives the same ordered sequence; an empty output can act as an identity. That resembles the monoid laws. It is a deliberately limited equivalence, not equality of React element trees or a promise that state behaves identically after regrouping. React documents that changing Fragment nesting can reset state in some cases.[3] Treat the tree and its component positions as part of the design when views contain state.

The box does not define an applicative or a monad. Those need additional operations and laws. For example, Reader computations can combine values obtained from a shared input or select a subsequent computation based on an earlier result, but naming this box view or adding concat does not establish either structure.

Choosing the boundary #

This technique is useful when several small, pure descriptions need the same input adaptation or output arrangement. It also makes the flow through a composition visible. For a single local JSX expression, a chain and a box may add more indirection than they remove.

The box does not replace Hooks for stateful behavior, render props for a component-controlled output slot, or HOCs for adapting a component type. It composes ordinary functions around descriptions. React still owns rendering, component identity, and state.

Notes

  1. The live demo uses the React 19.2.5 and react-dom 19.2.5 development modules. The view box is ordinary JavaScript, not a React API.

References

  1. React: React calls Components and Hooks (opens in a new tab) · Back
  2. React: Components and Hooks must be pure (opens in a new tab) · Back
  3. React: Fragment (opens in a new tab) · Back
  4. React: Preserving and Resetting State (opens in a new tab) · Back
  5. Resisting Higher Order and Accepting Functional (opens in a new tab)
  6. Data.Profunctor: input and output mappings (opens in a new tab) · Back