Sharing state and behaviour in render trees

Two components sitting in a render tree. S-H-A-R-I-N-G. First come values, then comes behaviour. 18 July 2022

# Tree of components

Graphical user interfaces are typically described as a tree-of-components. In such a tree structure, components can have within it nested and peer components. To enable highly interactive experiences, state and behaviour needs to be shared by different components within the tree. Let's examine how this can be achieved, using React  and Marko  as examples.

# Local state

In the simplest case, every component can have its own state and behaviour. Independent instances of the same component can each have its own copy. In Javascript, functions are first class citizens, so value here can be taken to represent behaviour too. Local state is not particularly useful, but is a good starting point.

function Example() {
const [local, setLocal] = useState(42);
return <div>{local}</div>
}
<let/local = 42/>
<div>${local}</div>

# Parent-child relationships

The simplest form of sharing is when there is a direct parent-child relationship. A component is considered a child if it is accessible by the parent; ie, the relationship is shallow. A parent's internal state directly affects its children within a single component. Parents usually pass a value to the child, and a callback function for the child to invoke to pass values back to the parent.

There are two ways this can be done. Most commonly, the relationship is hard-coded, whereby the child is invoked directly by the parent. From outside, the child is effectively hidden.

function Parent() {
const [thing, setThing] = useState(value);
return <Child thing={thing}/>
}

function Example() {
return <Parent/>
}
<!-- parent.marko -->
<let/thing = value/>
<child thing=thing/>

<!-- example.marko -->
<parent/>

It is also possible to make this relationship explicit, by drawing the link at the point of invocation. This has the benefit that arbitrary children can be attached to the same parent, enabling better composition.

function Parent ({ children }) {
const [thing, setThing] = useState(value);
return children(thing);
}

function Example() {
return (
<Parent>
{(thing) => <Child thing={thing}/>}
</Parent>
)
}
<!-- parent.marko -->
<let/thing = value/>
<attrs/{ renderBody }/>
<${renderBody} thing=thing/>

<!-- example.marko -->
<parent|thing|>
<child thing=thing/>
</parent>

# Deeply inherited relationship

The parent-child strategy above works fine for shallow relationships, where all the children are visible within the parent. When the relationships are much deeper, a different strategy to using contexts is needed to share the parent's state and behaviour within a sub-tree.

This strategy should be used sparingly though, as it tightly couples components, and thus make reuse and testing more difficult. Composition, as with the parent-child strategy above should be preferred.

React provides 2 different ways to consume the context, using a higher order component, and using hooks. Which to choose is a matter of preference.

const Thing = React.createContext();

function Ancestor({ children }) {
const [value, setValue] = useState(42);
return (
<Thing.Provider value={value}>
{children}
</Thing.Provider>
)
}

function NestedWithComponents() {
return (
<Thing.Consumer>
{(thing) => <div>{thing}</div>}
</Thing.Consumer>
)
}

function NestedWithHooks() {
const thing = useContext(Thing);
return (
<div>{thing}</div>
)
}
<!-- ancestor.marko -->
<attrs/{ renderBody }/>
<let/thing = 42/>
<set=thing>
<${renderBody}/>
</set>

<!-- nested.marko -->
<get/thing="ancestor"/>
<div>${thing}</div>

# Out of Tree Relationships

Contexts share in-tree variables to descendents in its sub-tree. Out-of-tree variables like those from an external store like Redux , or from browser APIs like the localStorage API , or CustomEvents API  sometimes need to be attached to the render tree. To do this, components that need the out-of-tree value can simply attach to it as needed as shown below.

function useShared() {
return outOfTreeValue;
}

function PlusOne() {
const sharedValue = useShared();
return <div>{sharedValue+1}</div>;
}

function MinusOne() {
const sharedValue = useShared();
return <div>{sharedValue-1}</div>;
}
<!-- shared.marko -->
<return=outOfTreeValue/>

<!-- plus-one.marko -->
<shared/sharedValue/>
<div>${sharedValue+1}</div>

<!-- minus-one.marko -->
<shared/sharedValue/>
<div>${sharedValue-1}</div>

React-Redux  is an example of this, except the store is attached once, and shared with the entire tree using a global context and subsequently accessed using useSelector and useDispatch. The context is not strictly needed, a custom version of useSelector and useDispatch could have just as easily be written to access the store directly by the components that need it.

# Calling a global a global

There are some contexts that are globally shared; things like internationalistion, and theming, for example. In many cases, it's tempting to hoist these contexts to the root of the render tree, and share it globally. If there are many of these, it can result in a thick layer of contexts at the root of the render tree.

An alternative, is to call these globals, well, globals! Access to these can be done using the out-of-tree method described above.

function Example() {
const { csrfToken } = getGlobal();
return (
<form>
<input type="hidden" value=csrfToken />
</form>
)
}
<!-- $global is part of the framework -->
<get/$global>
<form>
<input type="hidden" value=$global.csrfToken/>
</form>

Another advantage of doing this, is that the component is no longer coupled to another. It becomes easier to reason about and test. Further, for server rendered applications, it removes the need to provide a context server-side, and consume it client-side, which is often a limitation in many frameworks.

However, doing this requires a mechanism to supply the global variables to the render tree at all. You can see below that the React  APIs  do not provide a mechanism to do so. This has led to a proliferation of root-level context providers in the community. Compare this with the Marko APIs  that do provide such a mechanism. The $global object is available to the entire render tree. For server-rendered trees, the full object is available, but for client-rendered trees, only those explicitly allowed in serializedGlobals are available, so that server secrets can be used server-side and yet are not leaked to the client.

// server.js
import * as ReactDOMServer from 'react-dom/server';

const stream = ReactDOMServer.renderToPipeableStream(
<App/>,
streamOptions
);
const htmlString = ReactDOMServer.renderToString(
<App/>
);

// client.js
import * as ReactDOM from 'react-dom/client';

ReactDOM.hydrateRoot(
document.getElementById("app"),
<App/>,
hydrateOptions
);
// server.js
import template from "./template.marko";

template.render({
$global: {
serverSecret: "private-only",
csrfToken: generateToken(),
serializedGlobals: {
csrfToken: true
}
}
}, stream);

# Transclusion

Beyond just sharing state and behaviour, whole components with its own bundled state and behaviour can also be shared. This can often be used to avoid the need to explicitly share granular state and behaviour.

function Example() {
const Component=flipCoin ? PlusOne : MinusOne;
return <Component value=42 />
}
import plusOne from "<plus-one>";
import minusOne from "<minus-one>";

<${flipCoin ? plusOne : minusOne} value=42/>

# Portals

Compared to transclusion, which is a pull model inserting arbitrary components here, portals implement a push model; inserting arbitrary components there. This is client-side only, due to the dependence on the DOM. By rendering elsewhere, portals allow the DOM tree to be different to the render tree.

function Example() {
return ReactDOM.createPortal(
content,
document.getElementById(targetId)
)
}
<!-- npm install @marko-tags/portal -->
<portal target=targetId>
${content}
</portal>

# Disappearing components

The DSL  used to express the above tree relationships can significantly affect composability and refactorability.

Consider the following example, whereby the expanded state lives only as long as the loop iteration. React requires two components to express this relationship, while Marko allows the state to be expressed inlined in one component. The latter is more expressive, and does not impose boilerplate. If desired, a separate component is a quick cut-and-paste away. The syntax allows the developer freedom to think about the underlying problem. Components disappear and become merely code organisational tools, rather than hard requirements imposed by the DSL.

function Item({ item }) {
const [expanded, setExpanded] = useState(false);
return (
<>
<button onClick={() => setExpanded(!expanded)}
{expanded ? "[-]" : "[+]"}
</button>
{expanded && item.contents}
</>
)
}

function List() {
return items.map(item =>
<Item item={item} key={item.id}/>
)
}
<ul>
<for|item| of=items by=(item => item.id)>
<let/expanded=false/>
<button onClick() { expanded = !expanded }>
${expanded ? "[-]" : "[+]"}
</button>
<if=expanded>
${item.contents}
</if>
</for>
</ul>

# Putting it together

When assembling a render tree, prefer inter-component communication mechanism that are local first. Use parent-child relationships where possible, when the relationship is shallow. Passing whole components bundled with state and behaviour can also reduce code complexity. Deeper relationships can be served with context, but avoid hoisting the provider too high unneccessarily. Consider using global (to the render tree) variables if your framework supports it. Out-of-tree values can be pulled in at appropriate parts of the tree, such that they are localised at the point of invocation. Languages that allow more freedom of expression over rigid syntax can also significantly improve developer happiness.