Published: October 31, 2025
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.s...
Published: October 31, 2025
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 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:
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>;
14
And 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! 👇