Using React Hooks in Ionic React

Max Lynch - Jul 8 '19 - - Dev Community

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" />
Enter fullscreen mode Exit fullscreen mode

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)} />
  </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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' })
Enter fullscreen mode Exit fullscreen mode

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>
    </>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

And our reducer function is very basic:

const reducer = (state, action) => {
  if (action.type === 'setPuppers') {
    return { ...state, puppers: action.puppers }
  }
  return state;
}
Enter fullscreen mode Exit fullscreen mode

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>
  </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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>
</>
);
Enter fullscreen mode Exit fullscreen mode

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!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .