This post expects you to have had minor exposure to Typescript. If this isn’t the case I would recommend that you firstly read an introductory post, such as this one Not required, but certainly will help.
React hooks are officially a thing. “officially a thing”) Woot! . [I previously wrote](https://medium.com/@ctrlplusb/easy-peasy-global-state-in-react-w-hooks-421f5bf827cf “previously wrote” “I previously wrote”) about [Easy Peasy](https://github.com/ctrlplusb/easy-peasy “Easy Peasy” “Easy Peasy” “Easy Peasy”), a global state library for React that leverages hooks and focuses on having a super simple but powerful API.
easy-peasy.js
import { StoreProvider, createStore, useStore, useActions } from 'easy-peasy';
// 👇 create your store, providing the model
const store = createStore({
todos: {
items: ['Install easy-peasy', 'Build app', 'Profit'],
// 👇 define actions directly on your model
add: (state, payload) => {
// do simple mutation to update state, and we make it an immutable update
state.items.push(payload)
// (you can also return a new immutable instance if you prefer)
}
}
});
const App = () => (
// 👇 wrap your app to expose the store
<StoreProvider store={store}>
<TodoList />
</StoreProvider>
)
function TodoList() {
// 👇 use hooks to get state or actions
const todos = useStore(state => state.todos.items)
const add = useActions(actions => actions.todos.add)
return (
<div>
{todos.map((todo, idx) => <div key={idx}>{todo}</div>)}
<AddTodo onAdd={add} />
</div>
)
}
This post is going to take things to a whole new level by showing you how you can compliment this API with the power and safety offered by Typescript.
I am going to take us step by step through building a naive todos application. Whilst this is completely unimaginative, I hope its boring familiarity will allow readers to more easily focus on the typing and APIs.
The code for this post exists in this GitHub repository.
Right, let’s get to it.
To get going as fast as possible we’ll leverage Create React App, along with its Typescript support.
Firstly, bootstrap an application, specifying that it support Typescript.
npx create-react-app type-safe-state-ftw --typescript
Ok, change directory into your newly created application.
cd type-safe-state-ftw
Then install Easy Peasy.
npm install easy-peasy
This single dependency includes everything you to support robust global state within your application. Some of its features includes:
And it only carries a very respectable 9kb gzip cost — all dependencies included.
We will start by creating types to represent the model for our store.
model.ts
type TodosModel = {
items: string[];
}
type NotificationModel = {
msg: string;
}
export type StoreModel = {
todos: TodosModel;
notification: NotificationModel;
}
Fairly self explanatory. We will have some todos along with a notification msg.
Now we can use the StoreModel
whilst creating our store.
store.ts
import { createStore } from 'easy-peasy';
import { StoreModel } from './model';
// 👇
const store = createStore<StoreModel>({
todos: {
items: ["Install easy-peasy", "Build app", "profit"]
},
notification: {
msg: ""
}
});
export default store;
By providing our StoreModel
as a type parameter on createStore
we get two things: type checking that ensures our implementation matches the StoreModel
type, and auto completion whilst we do so.
The returned store now has all the type information baked in, so any interaction with it will have Typescript helping us.
FYI —
createStore
returns a Redux Store, with all the Store APIs being available against it.### Grabbing state directly off the Store
Sweet, let’s try grab some state directly of the store.
getState.ts
store.getState().todos.items;
// ["Install easy-peasy", "Build app", "profit"]
This operation is fully typed.
Zing.
Actions are responsible for updating the state on our model. Let’s expand our model types to indicate where we intend the actions to exist.
actions.ts
import { Action } from "easy-peasy";
type TodosModel = {
items: string[];
add: Action<TodosModel, string>; // 👈
}
type NotificationModel = {
msg: string;
set: Action<NotificationModel, string>; // 👈
}
export type StoreModel = {
todos: TodosModel;
notification: NotificationModel;
}
The Action
type is a generic type, where its first type argument is the model that it is operating against, and the second type argument is the type of the payload it will receive.
action-def.ts
// 👇model to operate against
Action<TodosModel, string>;
// 👆payload
It’s also completely fine for an Action
to contain no payload.
After updating our StoreModel
Typescript will be shouting at us that our store implementation does not meet its expectations.
Let’s implement the missing actions against our store.
actions-imp.ts
const store = createStore<StoreModel>({
todos: {
items: ["Install easy-peasy", "Build app", "profit"],
// 👇
add: (state, payload) => {
state.items.push(payload);
}
},
notification: {
msg: "",
// 👇
set: (state, payload) => {
state.msg = payload;
}
}
});
You can see the actions receive the state they relate to as well as any payload that was provided to them. We use the actions to update our state — under the hood these mutations are converted into an immutable update against our store.
FYI you can also return “new immutable state” within your actions if you prefer. I prefer the “mutable” form as it’s less error prone and more concise, but this is a matter of personal preference.
alt-action-form.ts
const store = createStore<StoreModel>({
todos: {
// ...
add: (state, payload) => {
return {
...state,
items: [
...state.items,
payload
]
};
}
},
// ...
});
We will get type checking and autocomplete whilst implementing the actions.
Similar to accessing state directly against the store, we can also fire our actions via the store. They are bound to the store’s dispatch
at the same path as they were defined within our model.
dispatch.ts
store.dispatch.todos.add("Finish reading this article");
And yep, typed again.
Before we can use our store within our React components we need to wrap our application with the StoreProvider
, providing the store
instance.
app.tsx
import React from "react";
import { render } from "react-dom";
import { StoreProvider } from "easy-peasy"; // 👈
import store from "./store";
render(
<StoreProvider store={store}>
<App />
</StoreProvider>,
document.querySelector("#root");
);
Almost there.
Easy Peasy ships with _hook_s to interact with the store within our React components. However, it will be great if we can ensure that the hooks contain all the type information about our store so that we don’t lose any of the type safety and autocomplete goodness we have gotten used to.
We can create pre-baked typed versions of our hooks via the [createTypedHooks]([https://github.com/ctrlplusb/easy-peasy#createtypedhooks)](https://github.com/ctrlplusb/easy-peasy#createtypedhooks) "https://github.com/ctrlplusb/easy-peasy#createtypedhooks)")
helper.
hooks.ts
import { createTypedHooks } from "easy-peasy";
import { StoreModel } from "./model";
const {
useActions,
useStore,
useDispatch
} = createTypedHooks<StoreModel>();
export { useActions, useDispatch, useStore };
Now whenever we need to use a hook from our hooks.ts
file they will be able to guard and guide us via the type information we provided to them.
The moment we have been leading up to. Here’s how we use our state and actions within our components.
todos.tsx
import { useState } from "react";
import { useStore, useActions } from "./hooks"; // 👈
export default function Todos() {
// 👇 pull out state from our store
const items = useStore(state => state.todos.items);
// 👇 pull out actions from our store
const add = useActions(actions => actions.todos.add);
const [newTodo, setNewTodo] = useState("");
return (
<div>
<ul>
{items.map((todo, idx) =>
<li key={idx}>{todo}</li>
)}
</ul>
<input
type="text"
onChange={e => setNewTodo(e.target.value)}
value={newTodo}
/>
<button onClick={() => add(newTodo)}>Add</button>
</div>
);
}
This Todos
component pulls out the todo items from the store, and subsequently renders them in a list. It allows us to add a new todo item. You will note that we not keeping our form state within our store. I highly recommend that transient state, such as form data, be created and used within the scope of your components themselves. In our example above we use the standard useState
hook that React provides to help us with this.
This implementation is very naive, and could definitely do with some more work, but I’ve intentional kept it simple to make it quicker to read and understand what is going on.
As we consuming our typed hooks within our component we get all the type benefits.
This can be hugely helpful when refactoring your store as you will immediately get error messages in any component that is consuming refactored state — allowing you to quickly cycle through the instances and fix them.
We can encapsulate operations such as network requests within “thunks”.
Let’s adjust the requirements for our store. We now need to ensure that any new todo items are sent to the backend via network request before adding it to the store.
We can use theThunk
to describe an action that will allow us to encapsulate this behaviour.
thunk.ts
import { Action, Thunk } from 'easy-peasy';
type TodosModel = {
items: string[];
saved: Action<TodosModel, string>;
save: Thunk<TodosModel, string>; // 👈
}
We have done two things here. Firstly, we added the save
thunk action, represented by the Thunk
type. We then renamed the existing add
action to saved
. The thought process being is that we will first persist our new todo via the save
thunk action, and subsequently add it to the store via the saved
action. It will become clearer later on why we have a pair of actions for this.
Let’s update our store implementation accordingly.
thunk-model.ts
import { thunk } from "easy-peasy"; // 👈
const store = createStore<StoreModel>({
todos: {
items: ["Install easy-peasy", "Build app", "profit"],
// 👇renamed
saved: (state, payload) => {
state.items.push(payload);
},
// 👇our thunk
save: thunk(async (actions, payload) => {
await todoService.save(payload);
actions.saved(payload);
})
},
// ...
});
As you can see the “thunk” action doesn’t receive the model state. Instead it receives the actions of the model it relates to. Therefore if we wish to update state we need to dispatch other “standard” actions to do so. This model is very much the same as found in popular libraries such as redux-thunk
.
Within our save
thunk action we make a call to our todoService
, then we dispatch the saved
action to ensure the todo is added to our store.
We can now update our component, ensuring that it calls the newly created save
thunk action instead.
todos-thunk.tsx
export default function Todos() {
const items = useStore(state => state.todos.items);
// 👇
const save = useActions(actions => actions.todos.save);
const [newTodo, setNewTodo] = useState("");
return (
<div>
...
<button onClick={() => save(newTodo)}>Add</button>
</div>
);
}
There are many other features — which I will avoid going through right now out of fear of creating an overly long post. Some of the additional elements you can declare on your model include:
I encourage you to read the Easy Peasy GitHub page for more information about the library itself, view a more extensive example of the Typescript integration, and get detailed information in regards to the APIs.
In case you missed it, the code for this post lives in this GitHub repository.
I hope you enjoyed the read. Any and all feedback is greatly welcomed.
Feel free to log any issues via the GitHub repo.
Or take the following course:
☞ Master ReactJS: Learn React JS from Scratch
☞ Learn ReactJS: Code Like A Facebook Developer
☞ ReactJS Course: Learn JavaScript Library Used by Facebook&IG
☞ React: Learn ReactJS Fundamentals for Front-End Developers
#reactjs