How to Write UI components with optionally controlled state

Alireza Mirian
Quick Code
Published in
8 min readFeb 22, 2019
image source

A broad range of user interface elements happen to be an example of the following abstraction:

A part of screen that is displayed differently based on some value (state) and offers a set of interactions for changing that value.

For example:

  • A plain old input element: It’s displays the value inside the input and provides some means of changing that value (typing into it, pasting something inside it, dragging a text inside it, undo/redo, etc).
  • A zippy/accordion (A header and a collapsible content): It displays its content expanded or collapsed based on an open state and provides some means (clicking its header) of changing (toggling) that.
  • A resizable window: It displays a rectangular surface which is positioned and sized based on a value of the form {x, y, width, height} and it provides some means of changing that value (dragging window header to move it, or dragging its resize handles at the corners to resize it).

So, it’s the value and the provided change mechanism that defines each of these UI elements. The way we may come up with an implementation of this “state and change mechanism” is what we are going to delve into, in the rest of this article. Although It’s pretty much a framework agnostic subject, I picked React which lets us come up with a pretty succinct and general solution with Hooks.

Let’s stick with the zippy component as a simple, yet real-world example. Here is what a zippy looks like. You can click the header to toggle the content. That’s it:

Example of a Zippy component

Independent of your framework of choice, the implementation of a zippy component mostly consists of these two pieces of logic:

  • Showing or hiding details based on open value.
  • Handling clicks in the header, to toggle open value.

It might be tempting to keep the state of that value inside the component and update it by handling the user’s click on the header.
Here is an implementation of a very simple zippy component that keeps its open state as a local state value:

Zippy1.tsx

From UX point of view, it completely adheres to the definition of a zippy. Its usage is also quite easy. You just need to specify the header and content like this: <Zippy header="...">content</Zippy>. And that’s it. You got a working zippy.
But what if you want to do something when the zippy is opened. There should be an API (prop) on the zippy component to let you know when it’s toggled (value is changed).
We can simply add an onToggle prop to our existing implementation:

Zippy2.tsx

The parent component now can get informed of changes in the zippy state but it still has no control over that state. This control is sometimes required. For example, you may have a bunch of them in your page and you want to control which one is opened based on the current route.
To have control over the state of the component, we need to lift the state up and let the parent component pass open state as an input (prop). As we no longer keep open state inside our component, all we can do when the user clicks on the header is to let the parent component know about the user’s intent. It’s up to the parent component to handle it.
This pattern is particularly useful for intercepting the user’s actions (validation for example).
Here is the third version of our zippy component which is fully controlled by its parent:

Zippy3.tsx

This stateless implementation is also quite minimal, in a sense that we can create the stateful version (Zippy1) on top of it, probably by passing it through a couple of higher order components like withState and withHandlers. In other words, the stateful version is like the stateless version plus state management baked in.

While this stateless version gives us full control over its behavior, it pushes state management boilerplate to upper levels of the component hierarchy. You are no longer able to get a working version of the zippy by a snippet like this:<Zippy header="...">content</Zippy>
You need to use it like this: <Zippy header="..." open="someSource" onToggle="someHandler">content</Zippy>.

What if we could have a zippy component which is smart enough to figure out whether we want to control its state or not. It could behave like Zippy1 when we don’t want to control it, while still supporting external control over its state when open prop is passed. You may have noticed that this kind of behavior is pretty much analogous to what react-dom offers for native form inputs. You can choose to use them in controlled or uncontrolled mode. In fact, there are a lot of components in the wild that have adopted this pattern of supporting optional control over the state.

So let’s implement another version of our zippy component which supports optional control over the state. To do so, we accept an optionalopen prop. We also maintain an open state for uncontrolled usages. When open prop is undefined, we are in uncontrolled mode. In response to user’s clicks, we either call onToggle or set our internal open state, based on being in controlled mode or not.

Zippy4.tsx

This is the most evolved version of our zippy component. Control over its state is an optional feature. You can still get informed of toggle events if the zippy is used uncontrolled.

With this approach, we can think of a “UI element which depends on a state and provides a mean of changing that state”, as a component which accepts two optional props: value and changeHandler (in Angular, value would be an input and changeHandler an output).
Depending on whether the value and the change handler are passed or not, we have the following 4 combinations of usages, each of which is valid in certain circumstances.

  • value ✓ ⠀ ⠀ ⠀ changeHandler ✓
    Controlled. The state of the value is lifted up to the parent component and is passed as a prop. Change requests are also handled in the parent component.
    Examples:
    An input that only accepts numbers. A tab view in which the selected tab is determined based on the current route.
  • value ✓ ⠀ ⠀ ⠀ changeHandler
    Controlled. Value is controlled (forced) and it’s independent of normal change mechanism provided by the component.
    Examples:
    A read-only input. A fixed window which cannot be moved or resized.
  • value ⠀ ⠀ ⠀ changeHandler ✓
    Uncontrolled.
    Value is kept as a local state in the component itself. The parent component will be informed of the values changes through the changeHandler.
    Examples: A draggable resizable window which is controlled by its normal means of interaction, but you want to show a warning when its dragged outside of a specific boundary.
  • value ⠀ ⠀ ⠀ changeHandler
    Uncontrolled. Value is kept as a local state in the component itself and you don't care about its changes.
    Examples: A tab view, zippy, window, etc. when you just want the default functionality of that component without caring about its state at any moment.

“It’s not at all important to get it right the first time. It’s vitally important to get it right the last time.”
— The art of programming, Andrew Hunt and David Thomas

Our final implementation of the zippy component (Zippy4) supports both controlled and uncontrolled modes. But a good portion of its code (lines 4-32) is dedicated to it, rather than the logic related to zippy itself (lines 34-40). In other words, two different concerns are mixed in our implementation. It becomes more obvious the moment we decide to implement another component (say a time picker) with the exact same behavior of supporting both controlled or uncontrolled usage.

One way to factor out this piece of logic is a HOC. It can accept a configuration about which prop to make optionally stateful and which prop(s) to consider as change handler(s). In fact, you can find a couple of existing implementations of such HOC, like withUncontrolledProps or uncontrollable. I also tried to write one in typescript a while ago (I don’t remember why, but likely because none of the existing ones was exactly what I wanted), but I got stuck in type definitions very quickly. After a while, I learned about the pretty new react feature: Hooks. It turns out it’s pretty easy to “use” hooks to come up with a simple, readable and intuitive solution for optionally controlled state, without any extra effort for making it typescript friendly.

UseControllableState

With React’s built-in useState hook, we can create a state variable in our component. It returns a stateful value and a function to update it. It also accepts an initial value. Creating a stateful (uncontrollable) version of our Zippy component (similar to Zippy1) with useState is pretty straightforward:

Zippy1WithHook.tsx

All we want now is the ability to optionally control the state value (and its setter) with props. We actually need a new version of useState which in addition to initialValue, accepts value and changeHandler. We call it useControllableState. If value is undefined, we are in uncontrolled mode and useControllableState acts like normal useState and also calls changeHandler (if passed) on state changes. Otherwise, we are in controlled mode.

Before writing useControllableState Let's write some tests which ensure it covers all 4 types of usages we previously talked about.

This is how we want to use it in our component:

This is very much similar to our stateful version of the Zippy with useState hook (Zippy1withHook). Roughly speaking, the only difference is that useState is replaced with useControllableState.

We also use a tiny utility for our tests which mounts our component and returns an object with an enzyme wrapper and a couple of utility functions:

The last usage type (when neither value nor changeHandler is passed), is the easiest one to test:

Now we add a test for the case when value is not passed but changeHandler is:

Next two test cases are for the controlled mode. First, we test that if the value is passed but there is no change handler, clicking button doesn’t change the value:

Finally, we test controlled mode with change handler:

Here is the full version of our tests:

useControllableState.test.tsx

It’s worth noting that, we could test useControllableState hook easier without rendering anything.

Writing an implementation of useControllableState is easier than what it may seem:

useControllableState.ts

It uses the state hook internally to keep state value. Like useState, it returns the value and a setter for that.
For determining the value, we should check if we are in controlled mode or not. If the value argument is not undefined we are in controlled mode and we return that value. Otherwise, we are in uncontrolled mode and we return the current state value.
For the setter, we return a function which calls changeHandler if passed, and also stores newValue to our local state (using the setter we got from useState). We could skip setting state if we are in the controlled mode which results in small performance improvement in some cases.

Further improvements

  • Using identical setter function: In order to be more consistent with useState hook, we can use callback hook to return the same setter, unless changeHandler is changed. We also need to avoid closure pitfall in the setter.
  • Warning when the control mode is changed: changing from controlled mode to uncontrolled mode (or vice versa) is normally an inadvertent bug. React itself shows a warning in this case. We can also handle that and show a warning.

Y̵o̵u̵ ̵c̵a̵n̵ ̵f̵i̵n̵d̵ ̵a̵ ̵b̵e̵t̵t̵e̵r̵ ̵v̵e̵r̵s̵i̵o̵n̵ ̵o̵f̵ ̵u̵s̵e̵C̵o̵n̵t̵r̵o̵l̵l̵a̵b̵l̵e̵S̵t̵a̵t̵e̵ ̵w̵i̵t̵h̵ ̵t̵h̵e̵s̵e̵ ̵e̵n̵h̵a̵n̵c̵e̵m̵e̵n̵t̵s̵ ̵i̵n̵ ̵u̵s̵e̵C̵o̵n̵t̵r̵o̵l̵l̵a̵b̵l̵e̵S̵t̵a̵t̵e̵ ̵n̵p̵m̵ ̵p̵a̵c̵k̵a̵g̵e̵.

UPDATE (2021–04–24): I recently found useControlledState, a similar hook in @react-stately/utils. react-stately is a part of react spectrum libraries. I recommend using that instead of useControllableState. The signature is a little different and it doesn’t support lazy default value.

Conclusion

A large number of UI components are a function of some state and they also provide some way(s) of changing that state. A flexible implementation of these components allows both stateful and stateless usages. In React, we can maintain local state in our components via useState hook. WithuseControllableState we can maintain a local state which is optionally controlled by props.

--

--