Controllable Components
Tldr
- Controlled components are driven by
input
props - 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
Uncontrolled components manage their own state.
<let/count=0>
<button onClick() {
count++;
}>
Count: ${count}
</button>
let/count=0
button onClick() {
count++;
}
-- Count: ${count}
<let/count=0>
<button onClick() {
count++;
}>
Count: ${count}
</button>
let/count=0
button onClick() {
count++;
}
-- Count: ${count}
Because <counter>
manages its own state (via <let>
), it can be used anywhere without extra work.
<counter/>
counter
<counter/>
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?
<counter/>
// 🤔 How can we access `count` out here?
<output>${count}</output>
counter
// 🤔 How can we access `count` out here?
output -- ${count}
<counter/>
// 🤔 How can we access `count` out here?
<output>${count}</output>
counter
// 🤔 How can we access `count` out here?
output -- ${count}
This isn't possible with only modifications to parent.marko
! Instead, we need to change <counter>
to give more control to its parent.
State synchronization
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
<let/count=0>
<button onClick() {
input.onChange(++count);
}>
Count: ${count}
</button>
let/count=0
button onClick() {
input.onChange(++count);
}
-- Count: ${count}
export interface Input {
onChange: (count: number) => void;
}
<let/count=0>
<button onClick() {
input.onChange(++count);
}>
Count: ${count}
</button>
export interface Input {
onChange: (count: number) => void;
}
let/count=0
button onClick() {
input.onChange(++count);
}
-- Count: ${count}
With this event handler, parent.marko
could keep track of its own copy of count
.
<let/count=0>
<counter onChange(newCount) {
count = newCount;
}/>
<output>${count}</output>
let/count=0
counter onChange(newCount) {
count = newCount;
}
output -- ${count}
<let/count=0>
<counter onChange(newCount) {
count = newCount;
}/>
<output>${count}</output>
let/count=0
counter onChange(newCount) {
count = newCount;
}
output -- ${count}
This approach leaves room for error:
- We have two
count
variables that must stay synchronized - If these variables get out of sync, our website will be broken
- We must track all changes in
<counter>
and synchronize them in the parent
As 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
Controlled components receive state from their parent and delegate changes back up the component tree.
<button onClick() {
input.updateCount(input.count + 1);
}>
Count: ${input.count}
</button>
button onClick() {
input.updateCount(input.count + 1);
}
-- Count: ${input.count}
export interface Input {
count: number;
updateCount: (count: number) => void;
}
<button onClick() {
input.updateCount(input.count + 1);
}>
Count: ${input.count}
</button>
export interface Input {
count: number;
updateCount: (count: number) => void;
}
button onClick() {
input.updateCount(input.count + 1);
}
-- Count: ${input.count}
If this <counter>
component is used directly, it won't be interactive! To manage <counter>
effectively, we need to create state in the parent.
<let/count=0>
<counter
count=count
updateCount(newCount) {
count = newCount;
}/>
let/count=0
counter
,count=count
,updateCount(newCount) {
count = newCount;
}
<let/count=0>
<counter
count=count
updateCount(newCount) {
count = newCount;
}/>
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:
- Every parent of
<counter>
needs this boilerplate, even if they don't usecount
- This refactor was only possible because we authored
<counter>
and can change its API
The Controllable Pattern
Ultimately, 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.
Controllable <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.
<let/count=input.count valueChange=input.countChange>
<button onClick() {
count++;
}>
Count: ${input.count}
</button>
let/count=input.count valueChange=input.countChange
button onClick() {
count++;
}
-- Count: ${input.count}
export interface Input {
count: number;
countChange?: (count: number) => void;
}
<let/count=input.count valueChange=input.countChange>
<button onClick() {
count++;
}>
Count: ${input.count}
</button>
export interface Input {
count: number;
countChange?: (count: number) => void;
}
let/count=input.count valueChange=input.countChange
button onClick() {
count++;
}
-- Count: ${input.count}
This component now has two behaviors, depending on the <let>
tag's valueChange
:
- When
countChange
is a function<let>
forfeits control of its state and acts as a derivation ofinput.count
- When
countChange
isundefined
<let>
acts just as it did in our first example
<let/parentCount=0>
// `parentCount` is the source of truth
<counter
count=parentCount
countChange(count) {
parentCount = count;
}/>
// This one holds its own state
<counter/>
let/parentCount=0
// `parentCount` is the source of truth
counter
,count=parentCount
,countChange(count) {
parentCount = count;
}
// This one holds its own state
counter
<let/parentCount=0>
// `parentCount` is the source of truth
<counter
count=parentCount
countChange(count) {
parentCount = count;
}/>
// This one holds its own state
<counter/>
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.
<let/count:=input.count>
<button onClick() {
count++;
}>
Count: ${input.count}
</button>
let/count:=input.count
button onClick() {
count++;
}
-- Count: ${input.count}
export interface Input {
count: number;
countChange?: (count: number) => void;
}
<let/count:=input.count>
<button onClick() {
count++;
}>
Count: ${input.count}
</button>
export interface Input {
count: number;
countChange?: (count: number) => void;
}
let/count:=input.count
button onClick() {
count++;
}
-- Count: ${input.count}
<let/parentCount=0>
<counter count:=parentCount/>
<output>${count}</output>
let/parentCount=0
counter count:=parentCount
output -- ${count}
<let/parentCount=0>
<counter count:=parentCount/>
<output>${count}</output>
let/parentCount=0
counter count:=parentCount
output -- ${count}
More Power
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:
<let/count=0>
<counter
count=count
countChange(c) {
if (c > 5) {
count = 5;
} else {
count = c;
}
}/>
let/count=0
counter
,count=count
,countChange(c) {
if (c > 5) {
count = 5;
} else {
count = c;
}
}
<let/count=0>
<counter
count=count
countChange(c) {
if (c > 5) {
count = 5;
} else {
count = c;
}
}/>
let/count=0
counter
,count=count
,countChange(c) {
if (c > 5) {
count = 5;
} else {
count = c;
}
}
or perform validation:
<let/count=0>
<counter
count=count
countChange(c) {
if (confirm("are you sure?")) {
count = c;
}
}/>
let/count=0
counter
,count=count
,countChange(c) {
if (confirm("are you sure?")) {
count = c;
}
}
<let/count=0>
<counter
count=count
countChange(c) {
if (confirm("are you sure?")) {
count = c;
}
}/>
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.
Further Reading
Contributors
Helpful? You can thank these awesome people! You can also edit this doc if you see any issues or want to improve it.