This site runs best with JavaScript enabled.

Stop using isLoading booleans


Why using a status enum (or even better: a state machine) will help your app stay bug free

Watch "Use a status enum instead of booleans" on egghead.io

Let's play around with the Geolocation API a bit and learn about the perils of isLoading booleans (and similar booleans like: isRejected, isIdle, or isResolved) while we're at it. I'll be using React to demonstrate this, but the concepts apply generally to any framework or language.

1function geoPositionReducer(state, action) {
2 switch (action.type) {
3 case 'error': {
4 return {
5 ...state,
6 isLoading: false,
7 error: action.error,
8 }
9 }
10 case 'success': {
11 return {
12 ...state,
13 isLoading: false,
14 position: action.position,
15 }
16 }
17 default: {
18 throw new Error(`Unhandled action type: ${action.type}`)
19 }
20 }
21}
22
23function useGeoPosition() {
24 const [state, dispatch] = React.useReducer(geoPositionReducer, {
25 isLoading: true,
26 position: null,
27 error: null,
28 })
29
30 React.useEffect(() => {
31 if (!navigator.geolocation) {
32 dispatch({
33 type: 'error',
34 error: new Error('Geolocation is not supported'),
35 })
36 return
37 }
38 const geoWatch = navigator.geolocation.watchPosition(
39 position => dispatch({type: 'success', position}),
40 error => dispatch({type: 'error', error}),
41 )
42 return () => navigator.geolocation.clearWatch(geoWatch)
43 }, [])
44
45 return state
46}

This is pretty standard stuff for code like this. I've seen this in both applications and open source libraries all over (React or not). However, there are some problems with this approach. Can you think of what those might be? Let me show you how a developer might use this particular hook:

1function YourPosition() {
2 const {isLoading, position, error} = useGeoPosition()
3
4 if (isLoading) {
5 return <div>Loading your position...</div>
6 }
7
8 if (position) {
9 return (
10 <div>
11 Lat: {position.coords.latitude}, Long: {position.coords.longitude}
12 </div>
13 )
14 }
15
16 if (error) {
17 return (
18 <div>
19 <div>Oh no, there was a problem getting your position:</div>
20 <pre>{error.message}</pre>
21 </div>
22 )
23 }
24}

Can you identify the problem yet? Yes? Awesome! No? No problem, let me help with a little real-world scenario.

Imagine what would happen if the user were to hop into a car, started driving around, and the device failed with a GeolocationPositionError.POSITION_UNAVAILABLE or GeolocationPositionError.TIMEOUT or even if the user decided to disable the geoposition permission for your app and you got a GeolocationPositionError.PERMISSION_DENIED. What would happen then? With the way the component above is written, we'd always show the last recorded position and never show the user the error message!

If we swapped things around and only show the position if there's no error, then we'd have the opposite problem: we'd only show an error, even if subsequent requests for the position succeeded.

There are a few ways we could change this to avoid that problem:

  1. Ensure users of the hook always show the position AND error if they're available.
  2. Clear the error when getting the position is successful, and clear the position if there's an error.
  3. Return an additional property indicating the current status of the geoposition information.

As a creator of something reusable, it's always easier to make it so people can't do the wrong thing even if they want to. Or at least it's more natural to do the right thing than the wrong thing (pit of success and all that). So option #1 is out.

For option #2, there may be some people who want to show the most recent position even if there was an error (and display the error message as well in that case), so that won't work.

So let's go with option #3:

1function geoPositionReducer(state, action) {
2 switch (action.type) {
3 case 'error': {
4 return {
5 ...state,
6 status: 'rejected',
7 error: action.error,
8 }
9 }
10 case 'success': {
11 return {
12 ...state,
13 status: 'resolved',
14 position: action.position,
15 }
16 }
17 case 'started': {
18 return {
19 ...state,
20 status: 'pending',
21 }
22 }
23 default: {
24 throw new Error(`Unhandled action type: ${action.type}`)
25 }
26 }
27}
28
29function useGeoPosition() {
30 const [state, dispatch] = React.useReducer(geoPositionReducer, {
31 status: 'idle',
32 position: null,
33 error: null,
34 })
35
36 React.useEffect(() => {
37 if (!navigator.geolocation) {
38 dispatch({
39 type: 'error',
40 error: new Error('Geolocation is not supported'),
41 })
42 return
43 }
44 dispatch({type: 'started'})
45 const geoWatch = navigator.geolocation.watchPosition(
46 position => dispatch({type: 'success', position}),
47 error => dispatch({type: 'error', error}),
48 )
49 return () => navigator.geolocation.clearWatch(geoWatch)
50 }, [])
51
52 return state
53}

You'll notice that we added another dispatch so we could differentiate from idle and pending states. In this particular case, there's not really a difference between the two (before we just started with isLoading as true which is effectively what this means), but there are cases where distinguishing between these two finite states is important and I worry that if I don't include them someone's going to copy/paste this and miss that important nuance.

Awesome, now users can use the status to render instead:

1function YourPosition() {
2 const {status, position, error} = useGeoPosition()
3
4 if (status === 'idle' || status === 'pending') {
5 return <div>Loading your position...</div>
6 }
7
8 if (status === 'resolved') {
9 return (
10 <div>
11 Lat: {position.coords.latitude}, Long: {position.coords.longitude}
12 </div>
13 )
14 }
15
16 if (status === 'rejected') {
17 return (
18 <div>
19 <div>Oh no, there was a problem getting your position:</div>
20 <pre>{error.message}</pre>
21 </div>
22 )
23 }
24
25 // could also use a switch or nested ternary if that's your jam...
26}

Play with this on codesandbox here

By using a status variable rather than a simple isLoading indicator we enable our users to know exactly what the state is at any given point in time.

Now, if you'd like to ditch those variable === 'string' expressions in those if statements because you like how terse a isBoolean is, have no fear, that's simple enough:

1const {status, position, error} = useGeoPosition()
2const isLoading = status === 'idle' || status === 'pending'
3const isResolved = status === 'resolved'
4const isRejected = status === 'rejected'

And one might argue that you could store those variables in your reducer's state rather than derive their values. But that leaves you vulnerable to impossible states! But, if you really want your users to not have to use the variable === 'string', then just make sure you stick with the status in your state to ensure you only have one possible finite state value and then you can derive boolean states as a convenience if you want to:

1function useGeoPosition() {
2 // ... clipped out for brevity ...
3 return {
4 isLoading: status === 'idle' || status === 'pending',
5 isIdle: status === 'idle',
6 isPending: status === 'pending',
7 isResolved: status === 'resolved',
8 isRejected: status === 'rejected',
9 ...state,
10 }
11}

State machines

Everything is a state machine, and this one is particularly noticeable. XState is an awesome library for representing state machines in code, so I thought I'd show you what that could be like:

1import {Machine, assign} from 'xstate'
2import {useMachine} from '@xstate/react'
3
4const context = {position: null, error: null}
5const RESOLVE = {
6 target: 'resolved',
7 actions: 'setPosition',
8}
9const REJECT = {
10 target: 'rejected',
11 actions: 'setError',
12}
13const geoPositionMachine = Machine(
14 {
15 id: 'geoposition',
16 initial: 'idle',
17 context,
18 states: {
19 idle: {
20 on: {
21 START: 'pending',
22 REJECT_NOT_SUPPORTED: 'rejectedNotSupported',
23 },
24 },
25 pending: {
26 on: {RESOLVE, REJECT},
27 },
28 resolved: {
29 on: {RESOLVE, REJECT},
30 },
31 rejected: {
32 on: {RESOLVE, REJECT},
33 },
34 rejectedNotSupported: {},
35 },
36 },
37 {
38 actions: {
39 setPosition: assign({
40 position: (context, event) => event.position,
41 }),
42 setError: assign({
43 error: (context, event) => event.error,
44 }),
45 },
46 },
47)
48
49function useGeoPosition() {
50 const [state, send] = useMachine(geoPositionMachine)
51
52 React.useEffect(() => {
53 if (!navigator.geolocation) {
54 send('REJECT_NOT_SUPPORTED')
55 return
56 }
57 send('START')
58 const geoWatch = navigator.geolocation.watchPosition(
59 position => send({type: 'RESOLVE', position}),
60 error => send({type: 'REJECT', error}),
61 )
62 return () => navigator.geolocation.clearWatch(geoWatch)
63 }, [send])
64
65 return state
66}

Play with this on codesandbox here

Watch me build this in my livestream

Firstly, if you're unfamiliar with state machines or xstate, be careful of familiarity bias against this code. It may not be straightforward to you today, but just like every abstraction, it becomes more straightforward the more time you spend with it.

You'll notice several things about this example. I added a special state for when geolocation is not supported. This state doesn't have any "event handlers" attached to it and is therefore called a "terminal state." That makes sense for our use case because if it's not supported then it's impossible to transition to any other state and our state machine ensures that will never happen.

But more than this, I want you to notice that there is no longer a status state we have to maintain. The status state is now just part of the machine's finite state value. So the status is built into the machine, rather than managed separately.

Here's how we would use that (with as minimal changes as possible):

1function YourPosition() {
2 const state = useGeoPosition()
3 const status = state.value
4 const {position, error} = state.context
5
6 if (status === 'rejectedNotSupported') {
7 return <div>This browser does not support Geolocation</div>
8 }
9
10 if (status === 'idle' || status === 'pending') {
11 return <div>Loading your position...</div>
12 }
13
14 if (status === 'resolved') {
15 return (
16 <div>
17 Lat: {position.coords.latitude}, Long: {position.coords.longitude}
18 </div>
19 )
20 }
21
22 if (status === 'rejected') {
23 return (
24 <div>
25 <div>Oh no, there was a problem getting your position:</div>
26 <pre>{error.message}</pre>
27 </div>
28 )
29 }
30}

And if we liked the previous API, we could preserve that API:

1function useGeoPosition() {
2 const [state, send] = useMachine(geoPositionMachine)
3 // ... clipped out for brevity ...
4 return {status: state.value, ...state.context}
5}

You may personally find the reducer example simpler or more understandable than the state machine. If you're unfamiliar with state machines or xstate then that's understandable. I'm not going to get into the merits of state machines in this post, but I have written about them a bit before: Implementing a simple state machine library in JavaScript.

Oh, and take a look at this implementation from David Khourshid (author of xstate) in which he completely removes the need for useEffect and instead puts the subscription logic within the machine itself!!! This is cool because it means that our machine is totally framework agnostic and can work regardless of which UI framework you're using (or even if you've built your own). He even built in a timeout (just for fun). Very cool stuff here.

Conclusion

At the end of all of this, I hope that you appreciate the value of defining the finite states that you have in your code and avoid bugs by doing so. This isn't a pitch for finite state machines as much as it is a plea for reducing our reliance on booleans that cannot represent all of the actual states our code can be in at any given time. Use a state machine or an enum. Not a boolean. Thank you, and good luck!

Discuss on TwitterEdit post on GitHub

Share article
loading relevant upcoming workshops...
Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. He's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.

Join the Newsletter



Kent C. Dodds