Black Sheep Code

Encapsulate as much state as possible in your component

Published:

Let's say we have a nice simple component like this:

☝️ Interactive demo

Click the button to see it transition from pending to loading to success.

You click the button, it transitions to a loading state, then it transitions to either a success or error state.

What I commonly see, is the component will be implemented with a interface like this:

Implementation

Whereby every bit of state that component could be in, is controlled by the parent and passed in as props.

Interfaces like this are a mistake

There's a sense that components with an interface like this would be easy to test, we could write something like:

It might be desirable to to write examples like this for Storybook, where we just want to see the component as it is loading.

But these tests do not test one of the key aspects of what we are interested in here - the state transitions.

If we did want to test the state transitions, we could do it one of two ways:

1. Create a wrapper component to contain the state in

Here, we are getting a good demonstration of 'tests are test of how usable your code is' - that we're having to create a bit of external state and manage its transitions in the test, is also what every consumer of our component is going to have to do.

2. Change the props via rerender

This is no test at all. We're simply stipulating that the props changed accurately, there's no guarantee that our parent component will in fact make this change correctly.

What this kind of interface really does, is it makes the parent responsible for the logic of the state transition from pending to loading to error/success.

Remember, the above example is a very simple component. An Autocomplete component, which we will look at later, will contain state about what text the user has entered, whether it is loading, are there results, and so forth.

The better interface - one that encapsulates the state transitions internally.

If instead we create an interface like this:

Note the difference being that this onClick handler returns a Promise<{success: boolean}>.

Implementation

Now we can write our tests like this:

This is much nicer!

Our test now resembles how we're going to use the component in practise, and it's very straight forward what's happening.

There's nothing say that you couldn't do both

Technically what we could do is do something like expose these components with less functionality as SpecialButtonStateless and then use this component in our SpecialButton component that then provides the the functionality.

I think this would be of limited use - but might be helpful in a larger team with a dedicated design system, and wanting to see the component state statically. I would argue that the functioning SpecialButton component that is the important for actually building the application.

Why is this pattern so common?

I suspect a big part of the reason that this pattern is so prevalent, is because of tools like Tanstack Query, which provide an interface that, to simplify, looks like this:

type LoadingResult<T> = {
  data: T; 
  state: "success"
}| {
  data: null;
  state: "pending" | "error" | "loading" 
}

that is, they're not providing async functions as their primary means of interface.

When developers see that their means for fetching data looks like this, then they're going to tend to reproduce that interface onto the components they're creating.

RTKQ does helpfully provide a useLazyQuery hook which lends itself more to the style that I advocate.

You can see this discussion on TanStack's Github the basic answer to why a lazy/imperative function is not provided, is because it would be trivial for someone to implement themselves.

Here is how we extract such a lazy query from TanStack - it really is not well advertised:

We use TanStack Query's queryClient.ensureQueryData which is essentially a lazy query that will return the data if it already cached.

This isn't an argument against state management tools like TanStack Query

This isn't at all to say that tools like Tanstack are bad. Tools like Tanstack are very useful in that they provide query deduplication and caching.

However, async functions as a particular example, are often the right abstraction to represent 'user does a thing, it loads for a bit, and then the state changes'.

A more complex example - Autocomplete

Here we have an Autocomplete component:

☝️ Interactive demo

Enter some search terms to see the behaviour of the Autocomplete component

Hint: Use the terms 'apple', 'cherry' or 'grape'

Importantly, an autocomplete is a non-trivial component. Done well, an autocomplete needs to handle:

  • Debouncing the search - so each keystroke doesn't trigger a new API request.
  • Cancelling previous requests - we don't want a situtation where a slower earlier request clobbers the the latest request.
  • Pagination - if the API response is paginated and the user scrolls the bottom of the list, we need to load more.

The above example does not implement these behaviours - this is an intentional decision - it represents the realistic evolution of a codebase as functionality is added or extended.

Keep this in mind.

A naive interface for this component might look like this:

And then we could use this in our application like this:

The consuming component needs to manage three pieces of state to make this work, and needs to implement all of the loading logic.

Now imagine if you had a form that had three of these Autocompletes! Imagine if you were also handling debouncing, cancellation and pagination logic!

Chances are, the developer will be copy-pasting what they see, and copy pasting is always prone to copy paste errors.

A better interface looks like this:

The use of it is much simpler:

And writing tests make sense:

Importantly, coming back to the debouncing, cancelling, pagination logic, all of that is encapsulated, hidden away inside, the component - the consumer does not need to think about, it's all taken care of for them.

Now you might think that that selectedValueDisplayStringFn stuff doesn't really look like we're simplifying the interface - but we are.

The component interface is forcing the developer handle the transition from 'having a search term entered and selecting an item' to 'an item is selected and something is displayed in the search box', and it does this does this in a manner that can't forgotten (you'll get a type error).

The complexity of the interface comes from the inherrent complexity of the problem space - and as much as possible, the component has already done the thinking for you as a developer - and it's telling you 'these are the decision you need to make - what should be displayed when an item is selected?'.



Questions? Comments? Criticisms? Get in the comments! 👇

Spotted an error? Edit this page with Github