Watch "Use a status enum instead of booleans" on egghead.io
Watch "Handle HTTP Errors with React" on egghead.io (part of The Beginner's Guide to ReactJS)
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.
function geoPositionReducer(state, action) {
switch (action.type) {
case 'error': {
return {
...state,
isLoading: false,
error: action.error,
}
}
case 'success': {
return {
...state,
isLoading: false,
position: action.position,
}
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
function useGeoPosition() {
const [state, dispatch] = React.useReducer(geoPositionReducer, {
isLoading: true,
position: null,
error: null,
})
React.useEffect(() => {
if (!navigator.geolocation) {
dispatch({
type: 'error',
error: new Error('Geolocation is not supported'),
})
return
}
const geoWatch = navigator.geolocation.watchPosition(
(position) => dispatch({ type: 'success', position }),
(error) => dispatch({ type: 'error', error }),
)
return () => navigator.geolocation.clearWatch(geoWatch)
}, [])
return state
}
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:
function YourPosition() {
const { isLoading, position, error } = useGeoPosition()
if (isLoading) {
return <div>Loading your position...</div>
}
if (position) {
return (
<div>
Lat: {position.coords.latitude}, Long: {position.coords.longitude}
</div>
)
}
if (error) {
return (
<div>
<div>Oh no, there was a problem getting your position:</div>
<pre>{error.message}</pre>
</div>
)
}
}
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:
- Ensure users of the hook always show the position AND error if they're available.
- Clear the
error
when getting the position is successful, and clear theposition
if there's an error. - 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:
function geoPositionReducer(state, action) {
switch (action.type) {
case 'error': {
return {
...state,
status: 'rejected',
error: action.error,
}
}
case 'success': {
return {
...state,
status: 'resolved',
position: action.position,
}
}
case 'started': {
return {
...state,
status: 'pending',
}
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
function useGeoPosition() {
const [state, dispatch] = React.useReducer(geoPositionReducer, {
status: 'idle',
position: null,
error: null,
})
React.useEffect(() => {
if (!navigator.geolocation) {
dispatch({
type: 'error',
error: new Error('Geolocation is not supported'),
})
return
}
dispatch({ type: 'started' })
const geoWatch = navigator.geolocation.watchPosition(
(position) => dispatch({ type: 'success', position }),
(error) => dispatch({ type: 'error', error }),
)
return () => navigator.geolocation.clearWatch(geoWatch)
}, [])
return state
}
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:
function YourPosition() {
const { status, position, error } = useGeoPosition()
if (status === 'idle' || status === 'pending') {
return <div>Loading your position...</div>
}
if (status === 'resolved') {
return (
<div>
Lat: {position.coords.latitude}, Long: {position.coords.longitude}
</div>
)
}
if (status === 'rejected') {
return (
<div>
<div>Oh no, there was a problem getting your position:</div>
<pre>{error.message}</pre>
</div>
)
}
// could also use a switch or nested ternary if that's your jam...
}
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:
const { status, position, error } = useGeoPosition()
const isLoading = status === 'idle' || status === 'pending'
const isResolved = status === 'resolved'
const 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:
function useGeoPosition() {
// ... clipped out for brevity ...
return {
isLoading: status === 'idle' || status === 'pending',
isIdle: status === 'idle',
isPending: status === 'pending',
isResolved: status === 'resolved',
isRejected: status === 'rejected',
...state,
}
}
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:
import { createMachine, assign } from 'xstate'
import { useMachine } from '@xstate/react'
const context = { position: null, error: null }
const RESOLVE = {
target: 'resolved',
actions: 'setPosition',
}
const REJECT = {
target: 'rejected',
actions: 'setError',
}
const geoPositionMachine = createMachine(
{
id: 'geoposition',
initial: 'idle',
context,
states: {
idle: {
on: {
START: 'pending',
REJECT_NOT_SUPPORTED: 'rejectedNotSupported',
},
},
pending: {
on: { RESOLVE, REJECT },
},
resolved: {
on: { RESOLVE, REJECT },
},
rejected: {
on: { RESOLVE, REJECT },
},
rejectedNotSupported: {},
},
},
{
actions: {
setPosition: assign({
position: (context, event) => event.position,
}),
setError: assign({
error: (context, event) => event.error,
}),
},
},
)
function useGeoPosition() {
const [state, send] = useMachine(geoPositionMachine)
React.useEffect(() => {
if (!navigator.geolocation) {
send('REJECT_NOT_SUPPORTED')
return
}
send('START')
const geoWatch = navigator.geolocation.watchPosition(
(position) => send({ type: 'RESOLVE', position }),
(error) => send({ type: 'REJECT', error }),
)
return () => navigator.geolocation.clearWatch(geoWatch)
}, [send])
return state
}
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):
function YourPosition() {
const state = useGeoPosition()
const { position, error } = state.context
if (state.matches('rejectedNotSupported')) {
return <div>This browser does not support Geolocation</div>
}
if (state.matches('idle') || state.matches('pending')) {
return <div>Loading your position...</div>
}
if (state.matches('resolved')) {
return (
<div>
Lat: {position.coords.latitude}, Long: {position.coords.longitude}
</div>
)
}
if (state.matches('rejected')) {
return (
<div>
<div>Oh no, there was a problem getting your position:</div>
<pre>{error.message}</pre>
</div>
)
}
}
And if we liked the previous API, we could preserve that API:
function useGeoPosition() {
const [state, send] = useMachine(geoPositionMachine)
// ... clipped out for brevity ...
return { status: state.value, ...state.context }
}
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!