The isLoading flag is a familiar sight in hooks and components, and it is rarely alone: it usually arrives with siblings such as isRejected, isIdle, or isResolved. Booleans of this shape look harmless, but they cannot describe all the states a piece of code can actually occupy. The examples below use the Geolocation API and React, though the problem is not specific to either.

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 standard code, found in applications and open source libraries regardless of framework or language. The hook might be consumed like this:

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>
		)
	}
}

Where the booleans break down

Consider a user who gets into a car and starts driving while the device fails with GeolocationPositionError.POSITION_UNAVAILABLE, GeolocationPositionError.TIMEOUT, or a GeolocationPositionError.PERMISSION_DENIED after the user disables the permission for the app. Given the component as written, the last recorded position would be displayed forever and the error would never surface.

Reversing the order so the position only renders when there is no error produces the mirror-image bug: an error stays on screen even after later position requests succeed.

Three possible corrections exist:

  1. Require consumers of the hook to render both the position and the error whenever both are available.
  2. Clear error after a successful position request, and clear position when an error occurs.
  3. Return an extra property describing the current status of the geolocation information.

A reusable primitive should make the wrong thing hard to do, or at least make the right thing more natural — the pit of success. Option 1 is therefore out. Option 2 fails because some consumers legitimately want to keep showing the most recent position alongside an error message. That leaves 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
}

The extra dispatch exists so idle and pending can be told apart. In this case the two are effectively the same thing, since the original code simply started with isLoading set to true. But the distinction matters in other cases, and omitting it invites copy/paste code that misses the nuance. Consumers can then branch on status:

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...
}

A status variable tells consumers exactly which state holds at any given moment, which a single isLoading indicator cannot do. Those who dislike variable === 'string' comparisons in if statements can still get terse booleans:

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

Storing such booleans in the reducer state rather than deriving their values is possible, but it exposes the code to impossible states. Keeping status as the single source of truth in state preserves one finite state value; boolean conveniences can then be derived from it:

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,
	}
}

Encoding the states in a machine

This hook is a state machine, whether or not it is written as one. XState is one way to express that in code:

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
}

Unfamiliarity with state machines or XState can make this version look harder than it is; like any abstraction, it reads more easily with exposure. One addition here is a dedicated state for unsupported geolocation. It carries no event handlers and is therefore a terminal state: from there no further transition is possible, and the machine guarantees none occurs.

The more significant change is that no status value has to be maintained separately. Status is now part of the machine's finite state value, built into the machine rather than managed alongside it. Consuming it requires minimal changes:

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>
		)
	}
}

The earlier API can also be preserved:

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

David Khourshid, the author of XState, has published an implementation that removes the need for useEffect entirely by putting the subscription logic inside the machine itself. That makes the machine framework agnostic — usable with any UI framework, or none — and he includes a timeout for good measure.

Takeaway

The point is not that finite state machines are the answer. It is that booleans cannot represent all the states code can actually be in at a given time, and that naming those finite states explicitly prevents bugs. Use a state machine or an enum. Not a boolean.