Encapsulate as much state as possible in your component
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:
4type SpecialButtonProps = {
5    onClick: () => void;
6    state: "loading" | "error" | "success" | "pending";
7};Implementation
9export function SpecialButton(props: SpecialButtonProps) {
10    return <button onClick={props.onClick} disabled={props.state === "loading"} className={`special-button ${props.state}`}>
11        {props.state === "loading" && <span>Loading...</span>}
12        {props.state === "error" && <span >Error!</span>}
13        {props.state === "success" && <span >Success!</span>}
14        {props.state === "pending" && <span>Click Me</span>}
15    </button>
16}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:
7    it("renders with pending state and correct text", () => {
8        const mockOnClick = vi.fn();
9
10        render(
11            <SpecialButton
12                onClick={mockOnClick}
13                state="pending"
14            />
15        );
16
17        expect(screen.getByRole("button")).toBeInTheDocument();
18        expect(screen.getByText("Click Me")).toBeInTheDocument();
19        expect(screen.getByRole("button")).not.toBeDisabled();
20    });
21
22    it("renders with loading state and is disabled", () => {
23        const mockOnClick = vi.fn();
24
25        render(
26            <SpecialButton
27                onClick={mockOnClick}
28                state="loading"
29            />
30        );
31
32        expect(screen.getByText("Loading...")).toBeInTheDocument();
33        expect(screen.getByRole("button")).toBeDisabled();
34    });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
96    it("transitions from pending to loading state", async () => {
97        const TestWrapper = () => {
98            const [state, setState] = React.useState<"loading" | "error" | "success" | "pending">("pending");
99
100            const handleClick = async () => {
101                await new Promise((res) => setTimeout(res, 50));
102                setState("loading");
103            };
104
105            return (
106                <SpecialButton
107                    onClick={handleClick}
108                    state={state}
109                />
110            );
111        };
112
113        render(<TestWrapper />);
114
115        const button = screen.getByRole("button");
116
117        // Initially should be in pending state
118        expect(screen.getByText("Click Me")).toBeInTheDocument();
119        expect(button).not.toBeDisabled();
120
121        // Click the button to trigger state change
122        await userEvent.click(button);
123
124        // Should now be in loading state
125        expect(await screen.findByText("Loading...")).toBeInTheDocument();
126        expect(button).toBeDisabled();
127    });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
129    it("responds to prop changes when rerendered", () => {
130        const mockOnClick = vi.fn();
131
132        const { rerender } = render(
133            <SpecialButton
134                onClick={mockOnClick}
135                state="pending"
136            />
137        );
138
139        let button = screen.getByRole("button");
140
141        // Initial state: pending
142        expect(screen.getByText("Click Me")).toBeInTheDocument();
143        expect(button).not.toBeDisabled();
144
145        // Rerender with loading state
146        rerender(
147            <SpecialButton
148                onClick={mockOnClick}
149                state="loading"
150            />
151        );
152
153        button = screen.getByRole("button");
154        expect(screen.getByText("Loading...")).toBeInTheDocument();
155        expect(button).toBeDisabled();
156    });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:
5type SpecialButtonProps = {
6    onClick: () => Promise<{ success: boolean }>;
7};Note the difference being that this onClick handler returns a Promise<{success: boolean}>.
Implementation
11export function SpecialButton2(props: SpecialButtonProps) {
12    const [state, setState] = useState<"loading" | "error" | "success" | "pending">("pending");
13
14    const handleClick = async () => {
15        setState("loading");
16        try {
17            const result = await props.onClick();
18            setState(result.success ? "success" : "error");
19        } catch (error) {
20            setState("error");
21        }
22    };
23
24    return <button onClick={handleClick} disabled={state === "loading"} className={`special-button ${state}`}>
25        {state === "loading" && <span>Loading...</span>}
26        {state === "error" && <span>Error!</span>}
27        {state === "success" && <span>Success!</span>}
28        {state === "pending" && <span>Click Me</span>}
29    </button>
30}Now we can write our tests like this:
7    it("manages async operation state internally from pending to loading to success", async () => {
8        const mockAsyncOperation = vi.fn().mockImplementation(
9            async () => {
10                await new Promise((res) => setTimeout(res, 100));
11                return { success: true };
12            }
13        )
14
15        render(<SpecialButton2 onClick={mockAsyncOperation} />);
16
17        expect(mockAsyncOperation).not.toHaveBeenCalled();
18
19        const button = screen.getByRole("button");
20
21        // Initially should be in pending state
22        expect(screen.getByText("Click Me")).toBeInTheDocument();
23        expect(button).not.toBeDisabled();
24
25        // Click the button to start async operation
26        userEvent.click(button);
27
28        // Should transition to loading state
29        expect(await screen.findByText("Loading...")).toBeInTheDocument();
30        expect(button).toBeDisabled();
31
32        // Wait for async operation to complete and transition to success
33        await waitFor(() => {
34            expect(screen.getByText("Success!")).toBeInTheDocument();
35        });
36
37        expect(button).not.toBeDisabled();
38        expect(mockAsyncOperation).toHaveBeenCalledOnce();
39    });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:
Lines 55 to 60 in 82a777f
55function useSearchFn() {
56    const qc = useQueryClient();
57    return useCallback(async (searchTerm: string) => {
58        return qc.ensureQueryData({ queryKey: ['search', searchTerm], queryFn: () => search(searchTerm) });
59    }, []);
60}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:
4export type AutocompleteProps<T> = {
5
6  searchValue: string;
7  onChangeSearchValue: (str: string) => void;
8
9  onSelectValue: (value: T) => void;
10  renderItem: (value: T) => React.ReactNode;
11
12  isLoading: boolean;
13  availableOptions: Array<T>;
14And then we could use this in our application like this:
63export function Interactive() {
64
65    // Just for storybook display purposes
66    const [selectedValue, setSelectedValue] = React.useState<Todo | null>(null);
67
68
69    const [searchValue, setSearchValue] = React.useState("");
70    const [availableOptions, setAvailableOptions] = React.useState<Array<Todo>>([]);
71    const [isLoading, setIsLoading] = React.useState(false);
72
73    React.useEffect(() => {
74
75    }, [searchValue]);
76
77    return <div>
78        <pre>
79            {JSON.stringify({ selectedValue, searchValue }, null, 2)}
80        </pre>
81        <Autocomplete
82            searchValue={searchValue}
83            onChangeSearchValue={async (str) => {
84                setSearchValue(str);
85                if (searchValue.length < 3) {
86                    setAvailableOptions([]);
87                    return;
88                }
89                setIsLoading(true);
90                try {
91                    const result = await searchFn(searchValue, 1);
92                }
93                catch {
94                    setAvailableOptions([]);
95                }
96                finally {
97                    setIsLoading(false);
98                }
99            }}
100            onSelectValue={(value) => {
101                setSelectedValue(value);
102                setSearchValue(value.name);
103                setAvailableOptions([]);
104            }}
105            renderItem={(v) => { return <div>{v.name}</div> }}
106            isLoading={isLoading}
107            availableOptions={availableOptions}
108        ></Autocomplete>
109    </div >
110}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:
15export type AutocompleteProps<T extends Record<string, unknown>, TKey extends keyof T> = {
16  searchFn: (searchTerm: string, pageNumber: number) => Promise<AutocompletePayload<T>>;
17  renderItem: (item: T) => React.ReactNode;
18  itemKey: TKey;
19
20  onSelectValue?: (itemKey: TKey, itemValue: T) => void;
21
22  /**
23   * When an item is selected, this function is used to determine what string appears in the input box. 
24   * @param item 
25   * @returns 
26   */
27  selectedValueDisplayStringFn: (item: T) => string;
28};The use of it is much simpler:
110export function Interactive() {
111    return (
112        <div>
113            <Autocomplete
114                searchFn={searchFn}
115                renderItem={(item) => <div>{item.name} - {item.description}</div>}
116                itemKey="id"
117                selectedValueDisplayStringFn={(item) => item.name}
118            />
119        </div>
120    );
121}And writing tests make sense:
202        it('should select item with Enter key', async () => {
203            const onSelectValue = vi.fn();
204            const searchFn = createMockSearchFn(true);
205
206            render(
207                <Autocomplete
208                    searchFn={searchFn}
209                    renderItem={(item: TestItem) => <div>{item.name} - {item.description}</div>}
210                    itemKey="id"
211                    onSelectValue={onSelectValue}
212                    selectedValueDisplayStringFn={(item: TestItem) => item.name}
213                />
214            );
215
216            const input = screen.getByPlaceholderText('Type to search...');
217
218            // Type to get results
219            await user.type(input, 'apple');
220
221            await waitFor(() => {
222                expect(screen.getByText('Apple - A red fruit')).toBeInTheDocument();
223            });
224
225            expect(screen.getByRole("list")).toBeInTheDocument();
226
227            // Navigate to first item and select with Enter
228            await user.keyboard('{ArrowDown}');
229            await user.keyboard('{Enter}');
230
231            // Verify selection
232            expect(onSelectValue).toHaveBeenCalledWith('id', mockItems[0]);
233
234            await waitFor(() => {
235                expect(screen.queryByRole("list")).not.toBeInTheDocument();
236            });
237            expect((input as HTMLInputElement).value).toBe('Apple');
238        });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