Dear state, please stop lying
updated 11 months ago
ยท
5 min read
Is managing the UI state is hard, or we make it hard for ourselves by not paying attention?
How do we do that? ๐ค
We have this React component fetches the list of something and renders accordingly.
class List extends React.Component {
state = { loading: false, data: [], error: null }
async componentDidMount() {
this.setState({ loading: true })
try {
const { data } = await api.getData()
this.setState({ loading: false, data })
} catch (error) {
this.setState({ loading: false, error })
}
}
render() {
if (this.state.loading) {
return <p>Loading...</p>
}
if (this.state.error) {
return (
<p>
Sorry, you broke the app. <br />
Here's what our server says: {this.state.error.message}
</p>
)
}
return (
<ul>
{this.state.data.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
}
}
Pretty straightforward, huh?
Probably not ๐ฌ
Our state initializes with
loading=false
and
fetchData
is called on
componentDidMount
which means, right after the initial render.
This will cause a flash in the
Loading
text since it's displayed after we render the empty list in our initial state (
data=[]
)
But programmers are pragmatic and smart people; we can fix this by initializing the state with
loading=true
?
โ
- No more flashing UI.
๐ฐ - We say that something is
loading
but that is not the
reality, nothing is
literally loading
until
fetchData
is called.
Our state is lying, but we can live with that.
Request failed? Oh, snap!
Let's take one more step forward for a better UX and add a retry button to the error component.
class List extends React.Component {
state = { loading: true, data: [], error: null }
componentDidMount() {
this.fetchData()
}
fetchData = async () => {
this.setState({ loading: true, error: null }) // WHY ๐คก
try {
const { data } = await api.getData()
this.setState({ loading: false, data })
} catch (error) {
this.setState({ loading: false, error })
}
}
render() {
if (this.state.loading) {
return <p>Loading...</p>
}
if (this.state.error) {
return (
<div>
<p>
Sorry, you broke the app. <br />
Here's what our server says: {this.state.error.message}
</p>
<button type="button" onClick={this.fetchData}>
Try again
</button>
</div>
)
}
return (
<ul>
{this.state.data.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
}
}
OK, this would work for our beloved users.
But readability is not optimal because we're doing some nasty things. We need to perform a clean-up since
error
might be a leftover from the first call.
Again, we may come up with an ingenious solution to make that bearable by introducing a custom
onRetry
method.
class List extends React.Component {
// ...
fetchData = async () => {
// this.setState({ loading: true, error: null }) Gone ๐
try {
const { data } = await api.getData()
this.setState({ loading: false, data })
} catch (error) {
this.setState({ loading: false, error })
}
}
onRetry = () => {
this.setState(
{
loading: true,
error: null,
},
() => this.fetchData(),
)
}
// ...
}
โ
- More readable and traceable.
๐ฐ - We are skipping a fundamentally crucial point, and our state is still lying.
Time to question our choices
- Why can our state have an error while loading is
true
? - Why does our state have
data=[]
while loading is true
? - Why is that possible to have
data
and error
at the same time?
๐จ๐ปโ๐ป: Dear state, pls stop lying.
๐ค: 01001000011001010110110001110000
How types can help us to make better choices
๐ฅ๐ฅ๐ฅ
Let's start with a tiny touch and strip the meaningless properties from different types of UI states.
type LoadingState = { type: 'loading' }
type SuccessState<T> = { type: 'sucess'; data: T }
type FailureState = { type: 'failure'; error: Error }
type State<T> = LoadingState | SuccessState<T> | FailureState
By revisiting our state design, we made all these questions irrelevant.
Why can our state have an error while loading is true
?Why does our state have data=[]
while loading is true
?Why is that possible to have data
and error
at the same time?
How would that affect our component?
class List extends React.Component {
// no more pointless initial values
state: State<{ id: number; name: string }[]> = { type: 'loading' }
componentDidMount() {
this.fetchData()
}
async fetchData() {
try {
const { data } = await api.getData()
this.setState({ type: 'success', data })
} catch (error) {
this.setState({ type: 'failure', error })
}
}
// no more nasty clean-ups
onRetry = () => this.setState({ type: 'loading' }, this.fetchData)
// no more top level if statements
render() {
switch (this.state.type) {
case 'loading':
return <p>Loading...</p>
case 'failure':
return (
<div>
<p>
Sorry, you broke the app. <br />
Here's what our server says: {this.state.error.message}
</p>
<button type="button" onClick={this.onRetry}>
Try again
</button>
</div>
)
case 'success':
return (
<ul>
{this.state.data.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
}
}
}
Looks better?
There are more benefits than making the state a trustworthy citizen.
- We have proper types for the
data
. - We can have proper types for the
error
by creating custom exception types (will discuss later). - TypeScript will also help us to catch programmer errors to prevent runtime exceptions, as shown below.
switch (this.state.type) {
case 'loading':
return <p>Loading: {this.state.data[0].id}</p> // TSC SAYS NO!
๐ค: 01001110011010010110001101100101
Resources