This post is a translation of the original article on our website
Why did we create ui-state, a TypeScript library to manage ui state display? It all started after reading an excellent article by Dominic Dorfmeister, aka TkDodo (we also recommend checking out his other posts on his blog).
In the article Component Composition is great btw, TkDodo highlights a recurring problem: managing UI states (loading
, error
, empty
, success
, etc.) in a way that is readable, maintainable, and type-safe without making your component structure explode.
The typical starting point.
You start by writing a simple component:
export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
return (
<Card>
<CardHeading>Welcome π</CardHeading>
<CardContent>
{data?.assignee ? <UserInfo {...data.assignee} /> : null}
{isPending ? <Skeleton /> : null}
{data
? data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))
: null}
</CardContent>
</Card>
)
}
At first glance, everything seems to "work."
But things get messy quickly:
- Can we have both
data
andisPending
at the same time? - Does the absence of
data
mean an error or an empty list? - What happens if
data
is present but empty?
You end up juggling several flags (isPending, data, isError, etc.) that can make two parts of the UI appear simultaneously, when that wasn't the intent.
It becomes hard to read, test, and maintain.
TkDodo's proposed solution
TkDodo suggests a clearer refactor based on early returns
:
function Layout(props: { children: ReactNode; title?: string }) {
return (
<Card>
<CardHeading>Welcome π {props.title}</CardHeading>
<CardContent>{props.children}</CardContent>
</Card>
)
}
export function ShoppingList() {
const { data, isPending } = useQuery(/* ... */)
if (isPending) {
return (
<Layout>
<Skeleton />
</Layout>
)
}
if (!data) {
return (
<Layout>
<EmptyScreen />
</Layout>
)
}
return (
<Layout title={data.title}>
{data.assignee ? <UserInfo {...data.assignee} /> : null}
{data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</Layout>
)
}
This version is much clearer, each state corresponds to a single render.
But there's a tradeoff: You have to extract the layout into a separate component, and what if you don't want the entire screen to change?
Layout
is duplicated in every branch. You also need to extract typing logic for the Layout
props. And if you want part of the interface (like a header or sidebar) to remain constant between states, or certain Layout
parts to depend on the state, your code structure starts to grow complex again.
What we wanted: a single, well-typed, active state, reusable anywhere
At BearStudio, we wanted to keep the same core principles:
- Only one active state at a time
- Exhaustive type safety
- Readable display logic
β¦but without breaking up the JSX or restructuring the entire render around state cases.
We wanted to be able to say:
"Give us the current state, we'll handle it. Just make sure we cover every case."
That's why we created ui-state
With ui-state
, you transform the response from a useQuery
(or any data source) into a single, explicit state, based on a single call to getUiState
.
import { getUiState } from '@bearstudio/ui-state';
export function ShoppingList() {
const query = useQuery(/* ... */);
const ui = getUiState((set) => {
if (query.status === 'pending') return set('pending');
if (!query.data || query.data.content.length === 0) return set('empty');
return set('default', { data: query.data });
});
return (
<Card>
<CardHeading>
Welcome π
{ui
.match(['pending', 'empty'], () => '')
.match('default', ({ data }) => data.title)
.exhaustive()}
</CardHeading>
<CardContent>
{ui
.match('pending', () => <Skeleton />)
.match('empty', () => <EmptyScreen />)
.match('default', ({ data }) => (
<>
{!!data.assignee && <UserInfo {...data.assignee} />}
{data.content.map((item) => (
<ShoppingItem key={item.id} {...item} />
))}
</>
))
.exhaustive()}
</CardContent>
</Card>
);
}
What we gain from this:
- A single, well-defined state, always up to date.
-
Type exhaustiveness via
.exhaustive()
ensures no case is forgotten. - Automatic type narrowing from TypeScript β for example, data is no longer optional since we've verified its existence.
- Full rendering freedom, without restructuring JSX around states.
- Better testability, you can test each UI state independently.
Same concept as in TkDodo's article, but no need to split into multiple components or wrap your entire JSX around state handling.
You keep clear logic and intact composition.
π GitHub: https://github.com/BearStudio/ui-state
Top comments (1)
This is a super clean and practical solution π
Iβve definitely wrestled with messy UI state logic in React apps β juggling isPending, isError, and data flags can get chaotic fast.
Love how ui-state simplifies it into one well-typed active state while keeping the JSX flow intact. Great work, team! π