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 istrue
? - Why is that possible to have
data
anderror
at the same time?
๐จ๐ปโ๐ป: Dear state, pls stop lying.
๐ค: 01001000011001010110110001110000
How types can help us to make better choices
It's time to enter the safe and shiny world of types, where we can stop making impossible state possible.
๐ฅ๐ฅ๐ฅ
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 }
Now we can use the simple but powerful discriminated (or tagged) unions feature of TypeScript.
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 istrue
?Why does our state havedata=[]
while loading istrue
?Why is that possible to havedata
anderror
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