docs/explanation/controllable-components.md
[!TLDR]
- Controlled components are driven by
inputprops- Uncontrolled components are driven by internal state
- Controllable components can be both controlled and uncontrolled
- Marko provides first-class patterns for building controllable components
In component-based frameworks, developers must know where the source of truth is for state. Typically, a decision is made at the component level about whether it should be controlled or uncontrolled.
Uncontrolled components manage their own state.
/* counter.marko */
<let/count=0/>
<button onClick() { count++ }>
Count: ${count}
</button>
Because <counter> manages its own state (via <let>), it can be used anywhere without extra work.
/* parent.marko */
<counter/>
However, since the state is created in counter.marko, count can only be accessed within the component. This means there's no way for a parent to use the state. For example, how might a parent use this count and display it elsewhere on the page?
/* parent.marko */
<counter/>
// 🤔 How can we access `count` out here?
<output>${count}</output>
This isn't possible with only modifications to parent.marko! Instead, we need to change <counter> to give more control to its parent.
A naive approach for allowing parents to access state is to trigger events when updates happen.
[!WARNING] This is an anti-pattern! It is almost always better to use the controllable pattern for cases like this instead of synchronizing state
/* counter.marko */
export interface Input {
onChange: (count: number) => void;
}
<let/count=0/>
<button onClick() {
input.onChange(++count);
}>
Count: ${count}
</button>
With this event handler, parent.marko could keep track of its own copy of count.
/* parent.marko */
<let/count=0/>
<counter onChange(newCount) { count = newCount }/>
<output>${count}</output>
This approach leaves room for error:
count variables that must stay synchronized<counter> and synchronize them in the parentAs we'll discuss later, most stateful native HTML elements use this "uncontrolled with state synchronization" approach by default. Marko extends these tags to enable the controllable pattern.
Controlled components receive state from their parent and delegate changes back up the component tree.
/* counter.marko */
export interface Input {
count: number;
updateCount: (count: number) => void;
}
<button onClick() { input.updateCount(input.count + 1) }>
Count: ${input.count}
</button>
If this <counter> component is used directly, it won't be interactive! To manage <counter> effectively, we need to create state in the parent.
/* parent.marko */
<let/count=0/>
<counter
count=count
updateCount(newCount) { count = newCount }
/>
This is great because the parent has full control over component state, but it has trade-offs:
<counter> needs this boilerplate, even if they don't use count<counter> and can change its APIUltimately, at component authoring time it's impossible to know whether we want state to be controlled or uncontrolled. It may need to be controlled sometimes but otherwise manage its own state. For these cases, Marko introduces the controllable pattern.
Controllable components are uncontrolled by default, but with a change handler they become controlled.
Before digging into our <counter> example and making it controllable, let's explore what this pattern looks like on native elements.
Most native HTML elements follow the uncontrolled pattern by default, but Marko enhances them with change handlers to enable the controlled pattern.
To take control of a stateful HTML element, we can add a Change handler.
<let/textValue="">
<input value=textValue valueChange(v) { textValue = v }>
Since valueChange is present, Marko knows this <input> is controlled and its value will always derive from textValue. This is called binding.
Because this is a common pattern, Marko provides a binding shorthand using the := operator.
<let/textValue="">
<input value:=textValue>
[!NOTE] The binding shorthand acts differently when used with an identifier versus a member expression.
<let>We want our <counter> tag to follow the same controllable pattern as native tags like <input> in Marko. Let's take advantage of the fact that <let> is also controllable.
/* counter.marko */
export interface Input {
count: number;
countChange?: (count: number) => void;
}
<let/count=input.count valueChange=input.countChange>
<button onClick() { count++ }>
Count: ${input.count}
</button>
This component now has two behaviors, depending on the <let> tag's valueChange:
countChange is a function
<let> forfeits control of its state and acts as a derivation of input.countcountChange is undefined
<let> acts just as it did in our first example/* parent.marko */
<let/parentCount=0>
// `parentCount` is the source of truth
<counter count=parentCount countChange(count) { parentCount = count }/>
// This one holds its own state
<counter/>
The binding shorthand accommodates both sides of this exchange, as it acts differently for identifiers and member expressions.
/* counter.marko */
export interface Input {
count: number;
countChange?: (count: number) => void;
}
<let/count:=input.count>
<button onClick() { count++ }>
Count: ${input.count}
</button>
/* parent.marko */
<let/parentCount=0>
<counter count:=parentCount/>
<output>${count}</output>
The shorthand may include a refining function that transforms the value before assignment. This is useful when the child component receives a broad type (e.g. native tag attributes may receive number | string) but the parent requires something more narrow.
<let/num=0/>
<input type="range" value:parseFloat:=num>
This example desugars to include parseFloat in its setter.
<let/num=0/>
<input type="range" value=num valueChange(v) { num = parseFloat(v)}>
This shorthand takes any function, including custom ones.
<let/text="HELLO">
<input value:uppercase:=text>
static function uppercase(str: string) {
return str.toUpperCase()
}
The controllable pattern allows the user of a component to decide whether to manage state. Simple cases remain simple, but complex state management is also possible.
We've only scratched the surface! When a parent hoists state up, it takes full control. This means we can add a max value:
/* parent.marko */
<let/count=0>
<counter count=count countChange(c) {
if (c > 5) {
count = 5;
} else {
count = c;
}
}/>
or perform validation:
/* parent.marko */
<let/count=0>
<counter count=count countChange(c) {
if (confirm("are you sure?")) {
count = c;
}
}/>
The key is that the parent decides what to do with state. If components are designed with the controllable pattern, they can be used in various scenarios without requiring changes to the component itself.