If you've been following the news in the React ecosystem, you've likely heard about the new React Hooks API available in React v16.8.
Hooks expose React features like state and context to functional, or non-class components. They also make it easier to share "stateful logic" between components, such as accessing data in a store, without complex wrapping components.
And now that Ionic supports React (in beta at the time of this writing, try it out!), we were excited to see how hooks can make common app building tasks and accessing native APIs really easy and clean, and wanted to walk through the new Hooks APIs in the context of an Ionic React app, including a real demo app that we will dig into at the end of this post.
We'll soon see there's more to Hooks than it seems!
Stateless Functional Components
Historically, functional components in React did not manage their own state, as there was no way to access these features outside of classes that extended React.Component
. This was partly why they were referred to as "Stateless Functional Components," and anything resembling state used in them was seen as a serious code smell (and likely broken).
Let's take a simple Stateless Functional Component for example:
export const MyComponent = ({ name }) => <h1>My name is {name}</h1>;
// Using the component
<MyComponent name="Max" />
In this example, MyComponent
is a functional component (i.e. it is not a class-based component), but it is also stateless, given that it manages none of its own internal state, and pure in the sense that it has zero side-effects (i.e. modifications it makes outside of itself, such as writing a file or updating a global variable). Rather, data is provided to the component through props
, such as name
, and they are merely rendered out by the component in predictable fashion.
Such limitations made Stateless Functional Components great for creating lots of small, presentational components, which are desirable in many situations. However, that still meant that doing anything more complex required class-based components.
Adding State to Functional Components
Hooks completely change what functional components can do in React, bringing state, async operations such as fetch, and APIs like Context to functional components in a safe, possibly even superior way (to their class-based counterparts, that is).
To illustrate this, let's modify this example to use Hooks to manage a small bit of internal state:
export const MyComponent = () => {
const [ name, setName ] = useState('Max');
return (
<>
<h1>My name is {name}</h1>
<IonInput value={name} onChange={(e) => setName(e.target.value)} />
</>
)
}
In this example, an IonInput
is used to type in a name, which is tracked in the internal state for the component and rendered in the <h1>
tag.
In the first line, we see our first use of Hooks with useState
. In this case, useState
hooks into the state management features in React, and creates a state variable. useState
takes an argument for the default value of the state variable, and then returns an array with two values which are destructured into two local variables: name
, and setName
.
The first value, name
in this case, is our state variable, and this is the one we render to the <h1>
element and set as the value
of the IonInput
. The second value, setName
is a function that we call to set the state variable's value. Both name
and setName
in this case can be called whatever we like.
Of course, most components will have many state variables, and thankfully we can call useState
as many times as we like, one for each variable.
If you're thinking that seems like magic because the MyComponent
function will be called each time the component re-renders and you're not sure how React keeps track of all the different useState
calls, then you're on to something. To make this work, React keeps track of the order in which the useState
calls were made, and thus has strict rules as to where useState
can be called (for example, calling it in a conditional statement is not allowed). To avoid issues, linting tools can help keep your use of useState
correct, but a good rule of thumb is to keep useState
calls at the top-level of the function and not nested inside any conditional or nested scopes. Basically, keep it simple!
Ionic React and React Hooks Example
Now that we have a basic understanding of hooks and managing a state variable, let's take a look at a more involved example of building a login form using Ionic React and React Hooks:
import React, { useState } from 'react';
import {
IonApp,
IonHeader,
IonTitle,
IonToolbar,
IonContent,
IonInput,
IonList,
IonItem,
IonLabel,
IonButton
} from '@ionic/react';
const LoginForm = () => {
const [ email, setEmail ] = useState('');
const [ password, setPassword ] = useState('');
const [ formErrors, setFormErrors ] = useState({});
const submit = async () => {
try {
await login({
email,
password
});
} catch (e) {
setFormErrors(e);
}
}
return (
<>
<IonHeader>
<IonToolbar>
<IonTitle>
Login
</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<form onSubmit={(e) => { e.preventDefault(); submit();}}>
<div>
{formErrors ? (
formErrors.message
): null}
</div>
<IonList>
<IonItem>
<IonLabel>Email</IonLabel>
<IonInput name="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)}/>
</IonItem>
<IonItem>
<IonLabel>Password</IonLabel>
<IonInput name="password" type="password" value={email} onChange={(e) => setPassword(e.target.value)}/>
</IonItem>
</IonList>
<IonButton expand={true} type="submit">Log in</IonButton>
</form>
</IonContent>
</>
)
}
What about TypeScript?
Before we move on, you may have wondered in the above examples (which are plain JS), how useState
and other hooks work with TypeScript. Thankfully, useState
is a generic function which can take type arguments if they can't be inferred.
For example, if we had a type of Car
that we wanted to set in state, we could call it this way:
const [ car, setCar ] = useState<Car>({ color: 'red' })
Hooks work great with TypeScript-based React apps!
Ionic React with a Class-Based React Component
The above examples are fun, and Hooks are certainly a quirky, curious new API that is oddly pleasing to use. However, one of the reasons they've practically blown up in the React community are because of the code simplicity benefits they bring.
To illustrate that, let's build the same example above but using the traditional React Class-based component approach:
import React, { useState, FormEvent } from 'react';
import {
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonList,
IonItem,
IonLabel,
IonInput,
IonButton
} from "@ionic/react";
export class LoginPage extends React.Component {
constructor(props) {
super(props);
this.state = {
email: '',
password: ''
}
}
async handleSubmit(e: FormEvent) {
e.preventDefault();
try {
const user = await login(email, password);
// ...
} catch (e) {
console.error(e);
}
}
handleInputChange(e) {
this.setState({
[e.target.name]: e.target.value
});
}
render() {
return (
<>
<IonHeader>
<IonToolbar color="primary">
<IonTitle>Login</IonTitle>
</IonToolbar>
</IonHeader>
<IonContent>
<form onSubmit={e => this.handleSubmit(e)} action="post">
<IonList>
<IonItem>
<IonLabel>Email</IonLabel>
<IonInput type="email" value={email} onInput={(e: any) => this.handleInputChange(e)} />
</IonItem>
<IonItem>
<IonLabel>Password</IonLabel>
<IonInput type="password" value={password} onInput={(e: any) => this.handleInputChange(e)} />
</IonItem>
<IonButton type="submit">Log in</IonButton>
</IonList>
</form>
</IonContent>
</>
);
}
}
In the above example, you'll notice a few hallmarks of class-based components: a constructor, calling this.state
, having to capture this
in callback handlers (in the above example we've used the arrow function approach for event handlers, but many use this.eventHandler = this.eventHandler.bind(this)
which has some serious gotchas).
While this example isn't very complicated, it's enough to show that this component is simpler as a functional component using Hooks than its class-based counterpart (though some may prefer the boilerplate of the class-based method, perhaps Java developers in another life).
Components with Side Effects: useEffect
State variables are just one use case for Hooks. Many components will need to do things that are considered "side effects" after a component is rendered (such as on mount or update). A side effect is any operation that causes something outside of the component to be modified as a side effect of using this Component. For example, making an API request is a side-effect that many Components need to perform.
This is where useEffect
comes in. For example, let's say we need to fetch some data from our component when it mounts by making a request to our API:
const MyComponent: = () => {
const [data, setData] = useState({});
useEffect(() => {
async function loadData() {
const loadedData = await getDataFromAPI();
setData(loadedData);
}
loadData();
}, []);
const items = (data.items || []);
return (
<div>
There are {items.length} entries
</div>
);
}
For class-based components, data fetching was often done in a lifecycle method such as componentDidMount
, and at first it's not obvious how calling useEffect
in the above works in comparison.
You can think of useEffect
as a combination of the lifecycle methods componentDidMount
, componentDidUpdate
, and componentWillUnmount
, given that it first runs as soon as the component is mounted and has rendered, will run every time the component is updated, and can run cleanup when the component will be unmounted.
However, in the above, we wouldn't want to fetch our data after every update! That could mean thousands of redundant API requests if a component is updated many times in short succession. Instead, useEffect
takes an extra argument of dependencies: useEffect(effectCallack, dependencyList)
. In dependencyList
, you can tell the effect to run only after certain state variables have changed, or pass an empty array to only allow the effect to run the first time the component is mounted.
In the above, we pass []
as the dependency list so that our effect only runs the first time the component is mounted.
One note: useEffect
is only necessary if you wish to perform the side-effects relative to renders of the component. If, instead, you wish to make an API request after an action (such as a button click in your component), just make the fetch normally and call the corresponding setter function for your state variable when data is returned and you wish to update the component. In this sense, useEffect
is a confusing name as you can incorporate side-effects in the component without needing to use it.
Easy state management with useContext
Most React developers know the struggle of trying to share global state across their application. For better or worse, this struggle has caused many developers to look at powerful solutions like Redux that were overkill for their needs, when something much simpler would have sufficed.
Well, with Hooks, Context, and the useContext
API, this struggle is effectively over. Accessing a global Context instance with useContext
makes it possible to do rudimentary state management or easily create your own mini Redux with no external dependencies and a simpler architecture.
We will walk through the useContext
hook in the example app at the end.
Custom hooks
React comes with a number of hooks out of the box, but they are useful for far more than just state management or accessing context!
In the following sections we will take a look at some custom hooks and how they can bring big benefits to React and Ionic app development.
Native APIs with Ionic and React Hooks
Because hooks are perfect for reusable, stateful logic, maybe they would be perfect for plugins that access Native APIs on iOS, Android, Electron, and the browser? Turns out they are, and we can build or use custom hooks to do just that!
Imagine accessing Geolocation APIs on the device. A custom hook called useGeolocation
might automatically listen for geolocation position changes and update a state variable:
const MyApp = () => {
const pos = useGeolocation();
return (
<span>Lat: {pos.lat}, Lng: {pos.lng}</span>
);
}
This example shows the hidden power of Hooks. With just one line, we have set up a geolocation query on component mount, which starts a watch that will update when our position changes, which will then update a state variable, which will cause the component to re-render and the updated position be displayed.
Now, imagine doing the same for other Native fetures like Camera, Storage, Photos, or Barcode Scanning, and you can get a sense for how easy hooks make interacting with these kinds of APIs.
So, how does this pertain to Ionic? Well, as Ionic React gets off the ground, we are exploring doing a set of hooks for the APIs available in Capacitor which we think will be pretty awesome!
An example app
With the introduction to hooks out of the way, let's take a look at a simple Ionic React app that uses a number of the hooks above, the Puppers app (source code here):
This app fetches a list of random images of adorable and Very Good puppers from the Dog API, with a few features that are completely overkill but also just right, including persisting the last images to localStorage, and a mini-implementation of redux for managing state through the Context API using just the useReducer
hook (and no external dependencies!). There is also a custom hook called useLocalStorage
that automatically loads and persists a key and value to localStorage (ported from usehooks.com).
This demo app also shows how to use Ionic React in a plain JS app without TypeScript.
App.js
In App.js, we have our main App component at the bottom of the file:
const App = () => {
return (
<IonApp>
<AppContextProvider>
<Puppers />
</AppContextProvider>
</IonApp>
);
}
export default App;
This component creates an IonApp
element, and then wraps the contents of the app with an AppContextProvider
which will be our main Context
for managing global state. Inside of that component, the Puppers
page is rendered. Pretty basic, and if you aren't familiar with the Context API, make sure to read more about it before continuing.
Next, we have the AppContextProvider
:
const AppContext = createContext();
const AppContextProvider = (props) => {
const [data, setData] = useLocalStorage('data', initialState);
let [state, dispatch] = useReducer(reducer, data);
let value = { state, dispatch };
useEffect(() => {
setData(state);
}, [state, setData]);
return (
<AppContext.Provider value={value}>{props.children}</AppContext.Provider>
);
}
This one is way more complex and uses a number of hooks and even a custom hook! Let's walk through some of it:
The first line calls our custom useLocalStorage
hook, which will load and automatically persist values to the data
item in localStorage:
const [data, setData ] = useLocalStorage('data', initialState);
Next, we create a reducer using useReducer
that takes a reducer function and an initial value, which we will pass in the data
state value from useLocalStorage
. This will cause the reducer to use any data loaded from localStorage as its initial value! If you haven't used redux before, useReducer
will likely be weird at first. However, it's a useful utility for complex state logic and lets us manage a single global state object that can be modified by actions in the application. Our application only has one action but you can imagine the average application having hundreds of actions. Read more about useReducer.
let [state, dispatch] = useReducer(reducer, data);
And our reducer function is very basic:
const reducer = (state, action) => {
if (action.type === 'setPuppers') {
return { ...state, puppers: action.puppers }
}
return state;
}
If this is confusing, hopefully seeing a component "use" the above context and reducer should make it more clear:
Puppers.js
Let's take a look at the Puppers Component, which loops through the list of puppers from the API and renders them one by adorable one:
export const Puppers = () => {
const { state, dispatch } = useContext(AppContext);
const fetchPuppers = useCallback(async () => {
const ret = await fetch('https://dog.ceo/api/breeds/image/random/10');
const json = await ret.json();
dispatch({
type: 'setPuppers',
puppers: json.message
})
}, [dispatch]);
useEffect(() => {
fetchPuppers();
}, [fetchPuppers]);
return (
<>
<IonHeader>
<IonToolbar>
<IonTitle>Puppers</IonTitle>
<IonButtons slot="end">
<IonButton onClick={() => fetchPuppers()}>
<IonIcon icon="refresh" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent>
{state.puppers.map(pupper => {
return (
<IonCard key={pupper}>
<IonCardContent>
<img src={pupper} />
</IonCardContent>
</IonCard>
)
})}
</IonContent>
</>
);
}
Let's take this line by line. The first line accesses the AppContext
that we instantiated using the <AppContextProvider>
component in our App
component, specifically the value
of the provider:
const { state, dispatch } = useContext(AppContext);
The state
variable will contain our global state in the context, and the dispatch
variable is a function we can call to send an action to our reducer (to update our state, for example).
Next, we define a function that we can use to call our API:
const fetchPuppers = useCallback(async() => {
const ret = await fetch('https://dog.ceo/api/breeds/image/random/10');
const json = await ret.json();
dispatch({
type: 'setPuppers',
puppers: json.message
})
}, [dispatch]);
Since we're going to call fetchPuppers
from a few different places in our component, we use the useCallback
hook to make sure the Hooks API properly understands the dependencies this function has. This was a solution to sharing a function in several hooks provided by Dan Abramov on his Complete Guide to useEffect, though there are alternative ways to achieve this. We provide the dispatch
function as a dependency to our fetchPuppers
call, as it will be called with fresh puppers once the response returns.
Next, we use useEffect
with an empty dependency list (i.e. []
as the last argument) to make a fetch as soon as this component is mounted:
useEffect(() => {
fetchPuppers();
}, [fetchPuppers]);
Finally, we render our component, and loop through each pupper, rendering them to the screen:
return (
<>
<IonHeader>
<IonToolbar>
<IonTitle>Puppers</IonTitle>
<IonButtons slot="end">
<IonButton onClick={() => fetchPuppers()}>
<IonIcon icon="refresh" />
</IonButton>
</IonButtons>
</IonToolbar>
</IonHeader>
<IonContent>
{state.puppers.map(pupper => {
return (
<IonCard key={pupper}>
<IonCardContent>
<img src={pupper} />
</IonCardContent>
</IonCard>
)
})}
</IonContent>
</>
);
A few things to see here: first, notice the onClick
event in the button in the toolbar. This will make a new fetch to the API, get 10 more random puppers, which will then cause global state to update, and our component to re-render.
Finally, given that we are using global state instead of local state, when we render out each pupper, we are accessing the state.puppers
field which came from the initial useContext
call.
And that's it!
Where to go from here
Despite React Hooks being very new, the community has created a plethora of interesting Hooks. One such library, react-use, has some simple yet powerful hooks such as useVideo (for easily interacting with an HTML5 video element). I personally love how clean and simple Hooks make interacting with stateful controls such as HTML5 media elements and APIs like localStorage.
Also, make sure to watch the React Conf Hooks Keynote Announcement by Dan Abramov, along with his great blog posts that dig into hooks in more detail, such as A Complete Guide to useEffect.
Finally, keep an eye out for some awesome hooks stuff from the Ionic team specifically for Ionic React apps (using Capacitor for native functionality). We love hooks and think they will make building apps considerably easier. And, if you haven't tried the Ionic React beta give it a shot and let us know what you think!
Any questions on using Hooks and how they might be useful in Ionic React apps specifically? Leave a comment below and we'll try to help!