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.

counter.marko
<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.

parent.marko
<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?

parent.marko
<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

counter.marko
<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.

parent.marko
<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.

counter.marko
<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.

parent.marko
<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 use count
  • 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 Native Tags

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;
  }>
let/textValue=""

input
  ,value=textValue
  ,valueChange(v) {
    textValue = v;
  }
<let/textValue="">

<input
  value=textValue
  valueChange(v) {
    textValue = v;
  }>
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>
let/textValue=""

input value:=textValue
<let/textValue="">

<input value:=textValue>
let/textValue=""

input value:=textValue

Note

The binding shorthand acts differently when used with an identifier versus a member expression. Above is the identifier behavior; we'll see the member expression behavior next.

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.

counter.marko
<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 of input.count
  • When countChange 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/>
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.

counter.marko
<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}
parent.marko
<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:

parent.marko
<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:

parent.marko
<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.