August 20, 2021 by Faisal Alghurayri
Jabal Alfil (Elephant Rock) - Al Ula, Saudi Arabia
This article is pretty long. I wrote the step-by-step guide that I wish were available when I started learning XState. It shows how to approach modeling a state machine, how to apply this approach using XState, and finally, how to integrate everything into the UI. I hope the pace will be right for you.
Have you ever built a web form to allow a user to submit data? If yes, do you think this is a simple or a complex task?
I would agree with both answers.
Payment form
A form is simply a collection of fields to capture data from the user.
However, the form’s complexity starts from a high baseline and exponentially increases by the requirements for each entry. For example, a developer needs to check and communicate to the user the validity of inputs before submissions, handle the submission loading, and gracefully allow the user to correct errors after submission.
Since complexity is a relative term, it is essential to establish a standardized way of simplifying, building, and communicating complex systems. One of the most helpful tools to do that is state machines.
If you have studied CS or searched the web for state machines, you will find it explained by something related to math and computation.
If that puts you off, forget such a definition for a bit.
In easy words: a state machine is:
Let take an example of a microwave:
Microwave State Machine
The above chart is easy to digest.
The squares are the states a microwave can be. The arrows are the events that the machine can process for each state. For this particular chart, all events are causing a transition from a state to another state.
One highlight is that firing the START_PRESSED
event while the microwave is cooking
will not do anything - the state machine doesn’t account for that.
However, suppose the microwave’s product team requested to add 30 seconds when the START_PRESSED
event happens while cooking
. Then, it is as simple as adding that transition to allow for it and incrementing 30 seconds when this event occurs.
If a system is deterministic, it is easy to reason about; therefore, it is simpler to extend and maintain.
If you are developing UI using React or a similar tool, you are probably already following a functional programming model in some of your components.
function LogLink({ isLoggedIn }) {
return <nav>{isLoggedIn ? <Logout /> : <Login />}</nav>;
}
The view (UI) depends on the state isLoggedIn
that can get changed by a LOGIN
or LOGOUT
events somewhere else in the system.
This pattern increases maintainability - If the logic to determine whether the user is logged in or not changes, it’s outside the scope of this view. In addition, it helps with testability - You can quickly test this view by supplying a true
or false
to the function. Finally, it is deterministic - You know that you will either see the login link or the logout link. Never both. Never neither.
Here is a great article that expands on the benefits of building pure UIs.
Pure UIs help you to simplify complex views. State machines help you build pure UIs.
Let’s start writing some code and discuss what we are doing. First, picking up from where we ended in my last article, How to Build Geospatial Apps, we have a non-interactive map displayed with a hardcoded marker and a popup.
Our PoC From the Last Article
Next Door is geospatial app that allows homeowners to list things they no longer need for their community.
Let’s lay out our goals:
That’s a sizable list of complex UI requirements. So let’s start tackling them using pure UIs and state machines!
A great practice when building UIs is to model the application logic outside the view. Let’s model our state machine and start interacting with it before writing a single line of React code. For that, I am going to use XState.
XState is a state machine library in JavaScript and TypeScript. I think it has the best feature set, documentation, and community.
As a test lap, let’s model our microwave state machine using XState
import { createMachine } from "xstate";
export const microwaveMachine = createMachine({
id: "microwave",
initial: "idle",
states: {
idle: {
on: {
PRESSED_START: "cooking",
},
},
cooking: {
on: {
PRESSED_OFF: "idle",
DOOR_OPENED: "idle",
TIMED_OUT: "idle",
},
},
},
});
Taking this machine to the XState visualizer will produce the following interactive diagram.
Microwave Machine Visualized
Pretty nice!
Feel free to copy-paste the machine into the visualizer and play around to feel how things work.
Going back to our app - First of all, let’s lay out the high-level states.
Create a new file named mapMachine.js
and add the following snippet
import { createMachine } from "xstate";
export const mapMachine = createMachine({
id: "mapMachine",
initial: "init",
states: {
init: {
on: {
MAP_LOADED: "ready",
},
},
ready: {},
},
});
Why do we have an init
state before the ready
state?
Since the requirement asks to automatically fetch the listings within the user’s viewport when they open the app, we will need an initialization step to allow the app to render the map before we use it. Only then will it be ready for subsequent usage.
Next, let’s start digging deeper by expanding the ready
state.
import { createMachine } from "xstate";
export const mapMachine = createMachine({
id: "mapMachine",
initial: "init",
states: {
init: {
on: {
MAP_LOADED: "ready",
},
},
ready: {
initial: "loading",
states: {
loading: {},
viewing: {},
adding: {},
},
},
},
});
Here we are dipping our toes into statecharts by utilizing hierarchical states.
Why are we doing nested states now?
To manage the state explosion problem that is about to start when we go beyond a handful of states with similar characteristics.
Statecharts extend the state machine concept and add a lot of power in the real world.
Once the transition from init
to ready
happens, the map will initially be in the ready.loading
state.
Let’s expand this ready.loading
state further
import { createMachine } from "xstate";
export const mapMachine = createMachine({
id: "mapMachine",
initial: "init",
states: {
init: {
on: {
MAP_LOADED: "ready",
},
},
ready: {
initial: "loading",
states: {
loading: {
initial: "byArea",
states: {
byArea: {},
nearMe: {},
},
},
viewing: {},
adding: {},
},
},
},
});
Here we describe that we should have two different types of loading - either by area (the viewport of the map) or by user’s location.
Now, we need a mechanism to perform the search by area.
XState encourages you to write your business logic in services
. Moreover, to aid in debugging and visualizing, it advises referencing each service by a string representing the service’s name.
import { createMachine } from "xstate";
export const mapMachine = createMachine({
id: "mapMachine",
initial: "init",
states: {
init: {
on: {
MAP_LOADED: "ready",
},
},
ready: {
initial: "loading",
states: {
loading: {
initial: "byArea",
states: {
byArea: {
invoke: {
id: "loadingByArea",
src: "fetchListingsByArea",
onDone: {
actions: "saveListings",
target: "viewing",
},
onError: {
actions: "logError",
target: "viewing",
},
},
},
nearMe: {},
},
},
viewing: {},
adding: {},
},
},
},
});
We introduced two concepts here.
First, how you can write a service
. XState allows for multiple types of services. You can invoke a promise, a callback, an observer, or even another machine. From XState point of view, it doesn’t matter. Any option is just an actor.
To ease the learning curve, I will go with using promises. Observe the onDone
and onError
transitions. They are what they look like - each is a destination based on the promise’s resolve or rejection. Either way, you will receive the event object with the data returned from the promise.
Second, XState has the concept of actions
. If you have experience with the reducer pattern by using Redux or useReducer, unlearn that for a bit.
In a nutshell, actions
are your opportunity to run code at a specific time between transitions. For example, in the above snippet, we save the result of fetched listings when they are resolved or log an error if something went wrong.
Let’s keep our momentum and revisit both concepts of services
and actions
later on.
Now, If you copy-pasted the above machine into the visualizer, you will see the following error message:
Error
The reason for this error is that we are telling the fetchListingsByArea
service that, after you are finished, regardless if you were a success or not, go to the viewing
state. This request is fair and expected!
The problem here is that viewing
is not a sibling for the byArea
state under the loading
state. Instead, it is nested up in the state tree.
Judging by the error message, there seems to be a dot notation access to traverse the nested state. Let’s use such dot notation and fix that error by assigning an id
to the ready
grandparent state. Then we can do the following
import { createMachine } from "xstate";
export const mapMachine = createMachine({
id: "mapMachine",
initial: "init",
states: {
init: {
on: {
MAP_LOADED: "ready",
},
},
ready: {
id: "ready",
initial: "loading",
states: {
loading: {
initial: "byArea",
states: {
byArea: {
invoke: {
id: "loadingByArea",
src: "fetchListingsByArea",
onDone: {
actions: "saveListings",
target: "#ready.viewing",
},
onError: {
actions: "logError",
target: "#ready.viewing",
},
},
},
nearMe: {},
},
},
viewing: {},
adding: {},
},
},
},
});
Now, this fix should clear the error!
Let’s shift gears into modeling the viewing
state. Again, as we did with loading
, there will be multiple nested states.
import { createMachine } from "xstate";
export const mapMachine = createMachine({
id: "mapMachine",
initial: "init",
states: {
init: {
on: {
MAP_LOADED: "ready",
},
},
ready: {
id: "ready",
initial: "loading",
states: {
loading: {
initial: "byArea",
states: {
byArea: {
invoke: {
id: "loadingByArea",
src: "fetchListingsByArea",
onDone: {
actions: "saveListings",
target: "#ready.viewing",
},
onError: {
actions: "logError",
target: "#ready.viewing",
},
},
},
nearMe: {},
},
},
viewing: {
initial: "viewingMap",
states: {
viewingMap: {},
viewingListing: {},
},
},
adding: {},
},
},
},
});
We have added two different states to distinguish between viewing the map or viewing the listing.
Why we did that?
Because when we click on the map while viewing the map, we need to add a new listing there. However, when we click on the map while viewing a listing, we need the relevant opened marker popup to get dismissed.
Finally, before we start filling out our events in each state, let’s model the adding
state.
import { createMachine } from "xstate";
export const mapMachine = createMachine({
id: "mapMachine",
initial: "init",
states: {
init: {
on: {
MAP_LOADED: "ready",
},
},
ready: {
id: "ready",
initial: "loading",
states: {
loading: {
initial: "byArea",
states: {
byArea: {
invoke: {
id: "loadingByArea",
src: "fetchListingsByArea",
onDone: {
actions: "saveListings",
target: "#ready.viewing",
},
onError: {
actions: "logError",
target: "#ready.viewing",
},
},
},
nearMe: {},
},
},
viewing: {
initial: "viewingMap",
states: {
viewingMap: {},
viewingListing: {},
},
},
adding: {
initial: "editing",
states: {
editing: {},
submitting: {},
},
},
},
},
},
});
Now we should have the following in the visualizer.
Checkpoint 1
Excellent progress!
But as you can see, we can’t traverse between different states because we have not yet declared the anticipated events that should result in state transition. So let’s start doing that now.
import { createMachine } from "xstate";
export const mapMachine = createMachine({
id: "mapMachine",
initial: "init",
states: {
init: {
on: {
MAP_LOADED: "ready",
},
},
ready: {
id: "ready",
initial: "loading",
states: {
loading: {
initial: "byArea",
states: {
byArea: {
invoke: {
id: "loadingByArea",
src: "fetchListingsByArea",
onDone: {
actions: "saveListings",
target: "#ready.viewing",
},
onError: {
actions: "logError",
target: "#ready.viewing",
},
},
},
nearMe: {},
},
},
viewing: {
initial: "viewingMap",
states: {
viewingMap: {
on: {
MAP_CLICKED: {
target: "#ready.adding",
actions: "saveClickedLocation",
},
MARKER_CLICKED: {
target: "viewingListing",
actions: "saveSelectedMarker",
},
},
},
viewingListing: {
on: {
MAP_CLICKED: {
target: "viewingMap",
actions: "clearSelectedMarker",
},
MARKER_CLICKED: {
target: "viewingMap",
actions: "clearSelectedMarker",
},
},
},
},
},
adding: {
initial: "editing",
states: {
editing: {
on: {
SUBMIT: "submitting",
CANCEL: "#ready.viewing.viewingMap",
},
},
submitting: {
invoke: {
id: "addNewListing",
src: "addNewListing",
onDone: {
target: "#ready.viewing.viewingMap",
actions: "saveNewListing",
},
onError: {
target: "editing",
actions: "logError",
},
},
},
},
},
},
},
},
});
The above increment should allow you to complete the whole flow of:
In a few lines of code and within a centralized place, we achieved most of the user requirements and graphed the states and transitions using the visualizer!
This is pretty powerful, isn’t it!
Now, I want to circle back to the concept of services
and actions
.
As you can see, we are hardcoding the name of all services and actions into the machine. Such hardcoding is good to help to make the machine serializable. Moreover, it helps in debugging, visualizing, and providing the ability to reuse this machine with different UIs (React, React Native, Vue, or any different framework).
Eventually, at some point, we need to tell the machine the definition of those functions.
We can achieve that by adding a configuration
object as a second parameter to the createMachine
function.
import { createMachine, assign } from "xstate";
export const mapMachine = createMachine(
{
// ... the previous machine definition
},
{
actions: {
logError: (ctx, ev) => console.log("Error", ev),
saveListings: assign({
markers: (ctx, ev) => ev.data.features,
}),
saveClickedLocation: assign({
clickedLocation: (ctx, ev) => ev.clickedLocation,
}),
clearClickedLocation: assign({
clickedLocation: (ctx, ev) => ({ lng: undefined, lat: undefined }),
}),
saveSelectedMarker: assign({
selectedMarker: (ctx, ev) => ev.marker,
}),
clearSelectedMarker: assign({
selectedMarker: (ctx, ev) => {},
}),
saveNewListing: assign({
markers: (ctx, ev) => ctx.markers.concat(ev.data),
}),
},
services: {
fetchListingsByArea: async (ctx, ev) => {
// The network call to fetch by area
const data = { features: [] };
return Promise.resolve(data);
},
},
}
);
Here we are introducing a couple of new things.
First is the assign action - a special XState action that allows us to save data into the machine. So, for example, the saveListings
action gets executed because the fetchListingsByArea
was just resolved with {features: []}
, allowing us to save these listings into the machine.
Second is the context
, which I abbreviated above as ctx
- where the above assign
function will save things to. You can provide an initial context in the machine definition. Let’s do that:
import { createMachine, assign } from "xstate";
export const mapMachine = createMachine(
{
// the initial context
context: {
markers: [],
clickedLocation: { lng: undefined, lat: undefined },
selectedMarker: undefined,
},
// ... the machine definition
},
{
actions: {
logError: (_ctx, ev) => console.log("Error", ev),
saveListings: assign({
markers: (_ctx, ev) => ev.data.features,
}),
saveClickedLocation: assign({
clickedLocation: (ctx, ev) => ev.clickedLocation,
}),
clearClickedLocation: assign({
clickedLocation: (_ctx, ev) => ({ lng: undefined, lat: undefined }),
}),
saveSelectedMarker: assign({
selectedMarker: (_ctx, ev) => ev.marker,
}),
clearSelectedMarker: assign({
selectedMarker: (_ctx, _ev) => {},
}),
saveNewListing: assign({
markers: (ctx, ev) => ctx.markers.concat(ev.data),
}),
},
services: {
fetchListingsByArea: async (_ctx, _ev) => {
// The network call to fetch by area
const data = { features: [] };
return Promise.resolve(data);
},
},
}
);
Let’s have a new checkpoint. Here is the latest code for our mapMachine.js
file:
import { createMachine, assign } from "xstate";
export const mapMachine = createMachine(
{
id: "mapMachine",
context: {
markers: [],
clickedLocation: { lng: undefined, lat: undefined },
selectedMarker: undefined,
},
initial: "init",
states: {
init: {
on: {
MAP_LOADED: "ready",
},
},
ready: {
id: "ready",
initial: "loading",
states: {
loading: {
initial: "byArea",
states: {
byArea: {
invoke: {
id: "loadingByArea",
src: "fetchListingsByArea",
onDone: {
actions: "saveListings",
target: "#ready.viewing",
},
onError: {
actions: "logError",
target: "#ready.viewing",
},
},
},
nearMe: {},
},
},
viewing: {
initial: "viewingMap",
states: {
viewingMap: {
on: {
MAP_CLICKED: {
target: "#ready.adding",
actions: "saveClickedLocation",
},
MARKER_CLICKED: {
target: "viewingListing",
actions: "saveSelectedMarker",
},
},
},
viewingListing: {
on: {
MAP_CLICKED: {
target: "viewingMap",
actions: "clearSelectedMarker",
},
MARKER_CLICKED: {
target: "viewingMap",
actions: "clearSelectedMarker",
},
},
},
},
},
adding: {
initial: "editing",
states: {
editing: {
on: {
SUBMIT: "submitting",
CANCEL: "#ready.viewing.viewingMap",
},
},
submitting: {
invoke: {
id: "addNewListing",
src: "addNewListing",
onDone: {
target: "#ready.viewing.viewingMap",
actions: "saveNewListing",
},
onError: {
target: "editing",
actions: "logError",
},
},
},
},
},
},
},
},
},
{
actions: {
logError: (_ctx, ev) => console.log("Error", ev),
saveListings: assign({
markers: (_ctx, ev) => ev.data.features,
}),
saveClickedLocation: assign({
clickedLocation: (ctx, ev) => ev.clickedLocation,
}),
clearClickedLocation: assign({
clickedLocation: (_ctx, ev) => ({ lng: undefined, lat: undefined }),
}),
saveSelectedMarker: assign({
selectedMarker: (_ctx, ev) => ev.marker,
}),
clearSelectedMarker: assign({
selectedMarker: (_ctx, _ev) => {},
}),
saveNewListing: assign({
markers: (ctx, ev) => ctx.markers.concat(ev.data),
}),
},
services: {
fetchListingsByArea: async (_ctx, _ev) => {
// The network call to fetch by area
const data = { features: [] };
return Promise.resolve(data);
},
},
}
);
And here is what the visualizer looks like (it’s a large gif, I suggest opening it in a new tab)
Checkpoint 2
Now, our machine is 80% ready to be used. We are missing a few things like allowing the user to manually fetch listings and controlling when the user should or shouldn’t pan the map. We will fill those gaps as we go.
Let’s go ahead and shift our focus to the UI and circle back to the machine when we need that.
Let’s start developing our UI and connecting it to the machine.
From where we stopped at, we have three components:
Map
Responsible for rendering a map and showing all markers and popups
import "mapbox-gl/dist/mapbox-gl.css";
import ReactMapGL from "react-map-gl";
import Marker from "./Marker";
import Popup from "./Popup";
export function Map() {
return (
<ReactMapGL
mapboxApiAccessToken={process.env.NEXT_PUBLIC_MAPBOX_KEY}
mapStyle="mapbox://styles/mapbox/streets-v11"
height="100%"
width="100%"
longitude={-80.897662}
latitude={35.484788}
zoom={13}
>
<Marker />
<Popup />
</ReactMapGL>
);
}
Marker
Responsible for showing a marker on the map.
import { Marker as ReactMapGLMarker } from "react-map-gl";
export function Marker() {
return (
<ReactMapGLMarker longitude={-80.897662} latitude={35.484788}>
<div
style={{
height: "2rem",
width: "2rem",
borderRadius: "50%",
background: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
⌨️
</div>
</ReactMapGLMarker>
);
}
Popup
Responsible for showing a popup on the map.
import { Popup as ReactMapGLPopup } from "react-map-gl";
export function Popup() {
return (
<ReactMapGLPopup longitude={-80.897662} latitude={35.484788}>
<div>
<pre>Almost new Apple keyboard</pre>
</div>
</ReactMapGLPopup>
);
}
And finally, the /pages/index.js
to render everything
import { Map } from "../lib/components/Map";
export default function Page() {
return (
<div style={{ height: "100vh", width: "100vw" }}>
<Map />
</div>
);
}
Here is how we are looking so far
Our PoC From the Last Article
Since we want to utilize our state machine to centralize state, data, and events, let’s move the hardcoded coordinates that resemble the center of the map from the <Map />
to the context
of the mapMachine
.
export const mapMachine = createMachine(
{
context: {
viewport: {
height: "100%",
width: "100%",
longitude: -80.897662,
latitude: 35.484788,
zoom: 13,
},
markers: [],
clickedLocation: { lng: undefined, lat: undefined },
selectedMarker: undefined,
},
// machine definition
},
{
// machine configuration
}
);
Next, to read this viewport, I need to somehow have access to it inside the <Map />
component.
To achieve that, let’s update the parent page component pages/index.js
file. I will bring in the machine, pass down the state
that holds all the information, and the send
function, which we can use to trigger the events.
Moreover, I will add a log statement to see how the state is going to change when an event is triggered.
import { useMachine } from "@xstate/react";
import { Map } from "./Map";
import { mapMachine } from "./mapMachine";
export default function Page() {
const [state, send] = useMachine(mapMachine);
console.log(state.value);
return (
<div style={{ height: "100vh", width: "100vw" }}>
<Map state={state} send={send} />
</div>
);
}
Now, in the Map.js
component, I will have access to the saved viewport by accessing the context from the state
import "mapbox-gl/dist/mapbox-gl.css";
import ReactMapGL from "react-map-gl";
import { Marker } from "./Marker";
import { Popup } from "./Popup";
export function Map({ state, send }) {
return (
<ReactMapGL
mapboxApiAccessToken={process.env.NEXT_PUBLIC_MAPBOX_KEY}
mapStyle="mapbox://styles/mapbox/streets-v11"
{...state.context.viewport}
>
<Marker />
<Popup />
</ReactMapGL>
);
}
If you refresh the app, you should see the same result. This means our latest refactor is successful!
However, by checking the console, you will see that the app is always in the init
state. Let change that by triggering the MAP_LOADED
event.
Now, since the map library doesn’t expose a reliable API to execute some code once the map is loaded and ready, let’s work around that by writing a setTimout
inside a useEffect
to trigger the MAP_LOADED
event.
import "mapbox-gl/dist/mapbox-gl.css";
import { useEffect } from "react";
import ReactMapGL from "react-map-gl";
import { Marker } from "./Marker";
import { Popup } from "./Popup";
export function Map({ state, send }) {
useEffect(() => {
setTimeout(() => {
send("MAP_LOADED");
}, 500);
}, []);
return (
<ReactMapGL
mapboxApiAccessToken={process.env.NEXT_PUBLIC_MAPBOX_KEY}
mapStyle="mapbox://styles/mapbox/streets-v11"
{...state.context.viewport}
>
<Marker />
<Popup />
</ReactMapGL>
);
}
Check the console and observe how once the above event is triggered, the transition from init
to ready: {loading: "byArea"}
will happen. Moreover, a transition from ready: {loading: "byArea"}
to ready: {viewing: "viewingMap"}
will automatically happen too as expected!
We are sailing! ⛵️
Remember - we haven’t yet implemented the logic to fetch the listings based on the viewport. Instead, we are returning an empty array.
Let’s fix that next.
The map library exposes an API to get the current map bounds (the lower-left and upper-right points). We can use this information to extract a polygon representing the viewport area. Only then will we be able to call our API to fetch the listings based on this area.
In other words, we need to have a ref
for the map inside the service. Eventually, the service is responsible for fetching the listings within the machine. We can save the mapRef
inside the machine context by utilizing the MAP_LOADED
event.
import "mapbox-gl/dist/mapbox-gl.css";
import { useEffect, useRef } from "react";
import ReactMapGL from "react-map-gl";
import { Marker } from "./Marker";
import { Popup } from "./Popup";
export function Map({ state, send }) {
const mapRef = useRef();
useEffect(() => {
setTimeout(() => {
send({
type: "MAP_LOADED",
mapRef,
});
}, 500);
}, []);
return (
<ReactMapGL
ref={mapRef}
mapboxApiAccessToken={process.env.NEXT_PUBLIC_MAPBOX_KEY}
mapStyle="mapbox://styles/mapbox/streets-v11"
{...state.context.viewport}
>
<Marker />
<Popup />
</ReactMapGL>
);
}
Let’s update our machine to welcome the mapRef
and save it to the context:
import { createMachine, assign } from "xstate";
export const mapMachine = createMachine(
{
// other definition
states: {
init: {
on: {
MAP_LOADED: {
target: "ready",
actions: "saveMapRef",
},
},
},
},
},
{
actions: {
saveMapRef: assign({
mapRef: (ctx, ev) => ev.mapRef,
}),
// other actions
},
// other config
}
);
Now, the service can access the mapRef
through the context.
import { createMachine, assign } from "xstate";
export const mapMachine = createMachine(
{
// definition
},
{
// other configuration
services: {
fetchListingsByArea: async (ctx, _ev) => {
const map = ctx.mapRef.current.getMap();
const bounds = map.getBounds();
const polygon = convertBoundToPolygon(bounds);
return await getListingsByArea(polygon);
},
},
}
);
If you refresh the page, you will see that a network call is done at the right time, and the listings are fetched successfully!
However, we are displaying the hardcoded marker.
Let’s fix this.
In the Map.js
component, update it to step over the list of fetched markers. Temporarily disable <Popup />
too.
import "mapbox-gl/dist/mapbox-gl.css";
import { useEffect, useRef } from "react";
import ReactMapGL from "react-map-gl";
import { Marker } from "./Marker";
import { Popup } from "./Popup";
export function Map({ state, send }) {
const mapRef = useRef();
useEffect(() => {
setTimeout(() => {
send({
type: "MAP_LOADED",
mapRef,
});
}, 500);
}, []);
return (
<ReactMapGL
ref={mapRef}
mapboxApiAccessToken={process.env.NEXT_PUBLIC_MAPBOX_KEY}
mapStyle="mapbox://styles/mapbox/streets-v11"
{...state.context.viewport}
>
{state.context.markers.map((marker) => (
<Marker key={marker._id} marker={marker} />
))}
{/* <Popup /> */}
</ReactMapGL>
);
}
Then, in the Marker.js
file, remove the hardcoded values and replace them with the values passed from the props
import { Marker as ReactMapGLMarker } from "react-map-gl";
export function Marker({ marker }) {
return (
<ReactMapGLMarker
longitude={marker.geometry.coordinates[0]}
latitude={marker.geometry.coordinates[1]}
>
<div
style={{
height: "2rem",
width: "2rem",
borderRadius: "50%",
background: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
{marker.properties.emoji}
</div>
</ReactMapGLMarker>
);
}
You should now see at least two listings displayed!
Listings Fetched Successfully
Next, let’s make the map interactive by allowing the user to pan.
The map library exposes an API called onViewportChange
, which will track how the user is panning the map and report the updated viewport. Such API is perfect for making the map feel interactive.
First, let’s update our machine to allow for UPDATE_VIEWPORT
event. Remeber, the user is allowed to pan the map only in the viewingMap
state.
import { createMachine, assign } from "xstate";
import { getListingsByArea } from "../../services/listings";
import { convertBoundToPolygon } from "../../services/map";
export const mapMachine = createMachine(
{
// other defintion
states: {
// other states
ready: {
states: {
viewing: {
states: {
viewingMap: {
on: {
UPDATE_VIEWPORT: {
actions: "saveNewViewport",
},
// other viewingMap events
},
// other viewing states
},
},
},
},
},
},
},
{
actions: {
saveNewViewport: assign({
viewport: (ctx, ev) => ev.newViewport,
}),
// other actions
},
// other config
}
);
And in the UI side, let’s add the event
import "mapbox-gl/dist/mapbox-gl.css";
import { useEffect, useRef } from "react";
import ReactMapGL from "react-map-gl";
import { Marker } from "./Marker";
import { Popup } from "./Popup";
export function Map({ state, send }) {
const mapRef = useRef();
useEffect(() => {
setTimeout(() => {
send({
type: "MAP_LOADED",
mapRef,
});
}, 500);
}, []);
return (
<ReactMapGL
ref={mapRef}
mapboxApiAccessToken={process.env.NEXT_PUBLIC_MAPBOX_KEY}
mapStyle="mapbox://styles/mapbox/streets-v11"
onViewportChange={(newViewport) =>
send({ type: "UPDATE_VIEWPORT", newViewport })
}
{...state.context.viewport}
>
{state.context.markers.map((marker) => (
<Marker key={marker._id} marker={marker} />
))}
{/* <Popup /> */}
</ReactMapGL>
);
}
Now, since the map already reads the viewport from the context, such viewport will get updated by the user’s panning. In other words, you will be able to pan through the map as expected!
Next, let’s improve the UI by showing a loading state while the map is initializing and when we are fetching the listings. I will do that in the pages/index.js
import { useMachine } from "@xstate/react";
import { useMemo } from "react";
import { Map } from "./Map";
import { mapMachine } from "./mapMachine";
export default function Page() {
const [state, send] = useMachine(mapMachine);
const isLoading = useMemo(() => {
return state.matches("init") || state.matches("ready.loading");
}, [state.value]);
return (
<div style={{ height: "100vh", width: "100vw" }}>
{isLoading && (
<div
style={{
height: "100vh",
width: "100vw",
background: "black",
display: "flex",
justifyContent: "center",
alignItems: "center",
color: "white",
}}
>
Loading
</div>
)}
<Map state={state} send={send} />
</div>
);
}
We are using the matches()
function to ask the state machine if something matches its state. With that, the loading feature is done!
Next, let’s allow the user to click on a marker to view the popup. Moreover, let’s handle clicking on the map, the marker, or the popup to close it.
The map library doesn’t expose an API to report when a marker is clicked. So let’s work around that by manually adding a click listener on the marker.
import { Marker as ReactMapGLMarker } from "react-map-gl";
export function Marker({ marker, send }) {
return (
<ReactMapGLMarker
longitude={marker.geometry.coordinates[0]}
latitude={marker.geometry.coordinates[1]}
>
<div
style={{
height: "2rem",
width: "2rem",
borderRadius: "50%",
background: "white",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
onClick={() => send({ type: "MARKER_CLICKED", marker })}
>
{marker.properties.emoji}
</div>
</ReactMapGLMarker>
);
}
The machine is already configured to receive and process the above event.
Let’s go back to the Map.js
component to show the popup only when a marker is selected.
import "mapbox-gl/dist/mapbox-gl.css";
import { useEffect, useMemo, useRef } from "react";
import ReactMapGL from "react-map-gl";
import { Marker } from "./Marker";
import { Popup } from "./Popup";
export function Map({ state, send }) {
const mapRef = useRef();
useEffect(() => {
setTimeout(() => {
send({
type: "MAP_LOADED",
mapRef,
});
}, 500);
}, []);
const isViewingListing = useMemo(() => {
return state.matches("ready.viewing.viewingListing");
}, [state.value]);
return (
<ReactMapGL
ref={mapRef}
mapboxApiAccessToken={process.env.NEXT_PUBLIC_MAPBOX_KEY}
mapStyle="mapbox://styles/mapbox/streets-v11"
onViewportChange={(newViewport) =>
send({ type: "UPDATE_VIEWPORT", newViewport })
}
{...state.context.viewport}
>
{state.context.markers.map((marker) => (
<Marker key={marker._id} marker={marker} send={send} />
))}
{isViewingListing && (
<Popup listing={state.context.selectedMarker} send={send} />
)}
</ReactMapGL>
);
}
Finally, update the popup to read from the prop
import { Popup as ReactMapGLPopup } from "react-map-gl";
export function Popup({ listing, send }) {
return (
<ReactMapGLPopup
longitude={listing.geometry.coordinates[0]}
latitude={listing.geometry.coordinates[1]}
onClose={() => send({ type: "MARKER_CLICKED" })}
>
<div>
<pre>{listing.properties.description}</pre>
</div>
</ReactMapGLPopup>
);
}
Here is what we got now
Clicking and Dismissing Markers
So far, so good! Two more requirements to go - adding a listing and searching near the user’s location.
Let’s handle adding a new listing by showing a form when the user clicks on the map in viewingMap
state.
First, let’s fire an event when the map is clicked.
import "mapbox-gl/dist/mapbox-gl.css";
import { useEffect, useMemo, useRef } from "react";
import ReactMapGL from "react-map-gl";
import { Marker } from "./Marker";
import { Popup } from "./Popup";
export function Map({ state, send }) {
const mapRef = useRef();
useEffect(() => {
setTimeout(() => {
send({
type: "MAP_LOADED",
mapRef,
});
}, 500);
}, []);
const isViewingListing = useMemo(() => {
return state.matches("ready.viewing.viewingListing");
}, [state.value]);
return (
<ReactMapGL
ref={mapRef}
mapboxApiAccessToken={process.env.NEXT_PUBLIC_MAPBOX_KEY}
mapStyle="mapbox://styles/mapbox/streets-v11"
onClick={({ lngLat }) =>
send({
type: "MAP_CLICKED",
clickedLocation: { lng: lngLat[0], lat: lngLat[1] },
})
}
onViewportChange={(newViewport) =>
send({ type: "UPDATE_VIEWPORT", newViewport })
}
{...state.context.viewport}
>
{state.context.markers.map((marker) => (
<Marker key={marker._id} marker={marker} send={send} />
))}
{isViewingListing && (
<Popup listing={state.context.selectedMarker} send={send} />
)}
</ReactMapGL>
);
}
Next, let’s listen for state change in the parent component and render the form when the state changes to what we expect
import { useMachine } from "@xstate/react";
import { useMemo } from "react";
import { Map } from "./Map";
import { mapMachine } from "./mapMachine";
export default function Page() {
const [state, send] = useMachine(mapMachine);
const isLoading = useMemo(() => {
return state.matches("init") || state.matches("ready.loading");
}, [state.value]);
const isAddingListing = useMemo(() => {
return state.matches("ready.adding");
}, [state.value]);
const isSubmittingListing = useMemo(() => {
return state.matches("ready.adding.submitting");
}, [state.value]);
return (
<div style={{ height: "100vh", width: "100vw" }}>
{isLoading && (
<div
style={{
height: "100vh",
width: "100vw",
background: "black",
display: "flex",
justifyContent: "center",
alignItems: "center",
color: "white",
}}
>
Loading
</div>
)}
{isAddingListing && (
<div
style={{
height: "100vh",
width: "100vw",
background: "black",
display: "flex",
justifyContent: "center",
alignItems: "center",
color: "white",
}}
>
<form
style={{ display: "flex", flexDirection: "column", color: "black" }}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
const data = Object.fromEntries(formData);
const { lng, lat } = state.context.clickedLocation;
send({
type: "SUBMIT",
data: {
...data,
lng,
lat,
},
});
}}
>
<input
style={{ margin: "5px", padding: "5px" }}
required
disabled={isSubmittingListing}
name="emoji"
placeholder="offer emoji (🛶)"
/>
<input
style={{ margin: "5px", padding: "5px" }}
required
disabled={isSubmittingListing}
name="price"
type="number"
placeholder="price (0 for free)"
/>
<input
style={{ margin: "5px", padding: "5px" }}
required
disabled={isSubmittingListing}
name="description"
placeholder="short description"
/>
<button
style={{ margin: "5px", background: "white", color: "black" }}
disabled={isSubmittingListing}
type="submit"
>
{isSubmittingListing ? "⏳" : "Add"}
</button>
</form>
</div>
)}
<Map state={state} send={send} />
</div>
);
}
Here we are utilizing the state machine by:
ready.adding
stateready.adding.submitting
stateOne missing part here is to add the addNewListing
service into the machine config.
import { createMachine, assign } from "xstate";
import { addListing, getListingsByArea } from "../../services/listings";
import { convertBoundToPolygon } from "../../services/map";
export const mapMachine = createMachine(
{
// machine definition
},
{
// other config
services: {
addNewListing: async (ctx, ev) => {
return await addListing(ev.data);
},
// other services
},
}
);
With that done, our form is ready! Let’s add a new listing
New listing added
Amazing! 🥳
Finally, I saved the most elegant code to the last - manually fetching listings near user’s location.
Let’s go back and fill everything related to this feature into the state machine.
import { createMachine, assign } from "xstate";
import { addListing, getListingsByArea } from "../../services/listings";
import { convertBoundToPolygon } from "../../services/map";
export const mapMachine = createMachine(
{
id: "mapMachine",
context: {
// ... other context
userLocation: { lat: undefined, lng: undefined },
},
states: {
// ... other state
ready: {
id: "ready",
states: {
loading: {
states: {
byArea: {
// ... already covered
},
nearMe: {
invoke: {
id: "loadingNearMe",
src: "fetchListingsNearMe",
onDone: {
target: "#ready.viewing.viewingMap",
actions: ["saveListings", "jumpToUserLocation"],
},
onError: {
target: "#ready.viewing.viewingMap",
actions: "logError",
},
},
},
},
},
viewing: {
initial: "viewingMap",
states: {
viewingMap: {
on: {
SEARCH_NEAR_ME: [
{
cond: "isUserLocationAcquired",
target: "#ready.loading.nearMe",
},
"#ready.acquiringUserLocation",
],
},
},
},
},
acquiringUserLocation: {
invoke: {
id: "getUserLocation",
src: "getUserLocation",
onDone: {
target: "#ready.loading.nearMe",
actions: "saveUserLocation",
},
onError: {
target: "#ready.viewing.viewingMap",
actions: "logError",
},
},
},
},
},
},
},
{
guards: {
isUserLocationAcquired: (ctx, _ev) =>
ctx.userLocation.lng !== undefined &&
ctx.userLocation.lat !== undefined,
},
actions: {
// ... other actions
saveUserLocation: assign({
userLocation: (_ctx, ev) => ev.data.userLocation,
}),
jumpToUserLocation: assign({
viewport: (ctx, _ev) => ({
...ctx.viewport,
longitude: ctx.userLocation.lng,
latitude: ctx.userLocation.lat,
zoom: 14,
}),
}),
},
services: {
// ... other services
fetchListingsNearMe: async (ctx, _ev) => {
return await getListingsNearMe(ctx.userLocation);
},
getUserLocation: async (_ctx, _ev) => {
return await getUserLocation();
},
},
}
);
We have a lot of great things to unpack.
First, when the SEARCH_NEAR_ME
event is triggered, there is a check to see if the location of the user is already acquired or not. This is achieved by utilizing a guard
(or a cond
).
If this condition evaluates to true, the transition to the #ready.loading.nearMe
state will happen, and we will start fetching listings near the user.
However, if this is not true, the next element of the array will be evaluated. In this case, a transition to a new state called acquiringUserLocation
will happen.
In that state, we are invoking a service that will ask the user for permission to access the browser’s geolocation API to determine their location. If things go wrong, the user will be taken back to the #ready.viewing.viewingMap
state.
However, If things go well, a transition back to the #ready.loading.nearMe
state will happen with the user’s location, and the API request to fetch listings near the user will be made successfully! Not only that - we will also jump to the user’s location to show them the listings near them automatically! 🤯
Look at that again.
No if statements.
No setState
, multiple dispatch
, or scattered middleware.
The UI’s job was just to send the event SEARCH_NEAR_ME
, and the state machine took care of orchestrating the rest in a few lines of code! ❤️
That is all that I want to share. Here is the final viz if you want to play with the state machine.
If you want to see a more organized version of this code that is optimized for React, check the repo. In summary, instead of useMachine
, I opted to go with interpret
, which will return a service
that never changes so I can pass it around using React Context and access it using useSelector
. This greatly helps to minimize wasted rerenders. I also hide a lot of the logic behind custom hooks.
Take it easy,
~Faisal