The full example code can be found on Code Sandbox

One of the most common tasks in a single page application is calling HTTP APIs. Things like fetching some data to show on load or submitting a form. Often to do something interesting you need to interact with an API. React hooks make dealing with asynchronous actions easier with useEffect. We’re going to look at ways we can make it even better using Effector.

Effector is a state management library, it allows you to create stores and update them with events. It’s stores are like Redux stores and its events are like actions but what makes it interesting is it has effects. Effects are a construct made to help us work with asynchronous tasks. We’re going to walk through things we need to do to make an API call using only React with no libraries. This way we can see all the elements in handling an API call and how we can improve on that using Effector.

We’re going to keep it simple and fetch a list of beers on page load from Punk API.

const [beers, setBeers] = useState([]);

useEffect(() => {
  async function fetchData() {
    const response = await fetch("https://api.punkapi.com/v2/beers");
    const json = await response.json();
    setBeers(json);
  }
  fetchData();
}, [setBeers]);

We fetch the list of beers, parse the JSON and set the beers state. We can map over the beers and render them in a list later on in the component. So far so good but we have a small problem. If the API takes a long time to return, the user will be staring at a blank screen with no feedback. It would be a good idea to add a loading state so it’s clear something is happening.

const [beers, setBeers] = useState([]);
const [isLoading, setLoading] = useState(false);

useEffect(() => {
  async function fetchData() {
    setLoading(true);
    const response = await fetch("https://api.punkapi.com/v2/beers");
    const json = await response.json();
    setBeers(json);
    setLoading(false);
  }
  fetchData();
}, [setBeers, setLoading]);

Alright, now we’re talking. We’ve got another issue though, we’re not doing any error handling! If something goes wrong we want to be able to handle that. Our app crashing or showing a blank screen could leave our users scratching their heads.

The call to response.json() can throw an error if we don’t receive well-formed JSON. The API could also return a non-200 status code to indicate an error. We have to check the status code on the response to figure out if we’re dealing with an error. Let’s add the error handling and see what the full component looks like:

import React, { useEffect, useState } from "react";

export default function ReactOnly() {
  const [beers, setBeers] = useState([]);
  const [isLoading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [selected, setSelected] = useState(null);

  useEffect(() => {
    async function fetchData() {
      setLoading(true);
      try {
        const response = await fetch("https://api.punkapi.com/v2/beers");
        if (!response.ok) {
          throw Error(response.statusText);
        }
        const json = await response.json();
        setBeers(json);
      } catch (e) {
        setError(e);
      } finally {
        setLoading(false);
      }
    }
    fetchData();
  }, [setBeers, setLoading, setError]);

  return (
    <>
      <h2>React List</h2>
      {isLoading && "Loading..."}
      {error && "Whoops something went wrong"}
      <section className="Columns">
        <ul className="BeerList">
          {beers.map((beer) => (
            <li key={beer.id}>
              <button
                className="Beer"
                onClick={() => {
                  setSelected(beers.find((b) => b.id === beer.id));
                }}
              >
                {beer.name}
              </button>
            </li>
          ))}
        </ul>
        {selected && (
          <div className="Selected">
            <p>{selected.description}</p>
            <p>{selected.abv} abv</p>
            <img height="200" src={selected.image_url} alt={selected.name} />
          </div>
        )}
      </section>
    </>
  );
}

We added a try/catch block surrounding the API call. It will pick up any possible errors and if we receive a response that is not ok we can throw an error. Now we can distinguish between a successful call and an error.

Achieving the same result with Effector

In our pure React example we saw the code became more complex when we added loading and error states. The code not only grew in size but it required a lot of manual tracking and updating. Let’s compare the same result using Effector.

import React, { useEffect } from "react";
import { useStore, useList } from "effector-react";
import {
  $beers,
  $selectedBeer,
  $beersError,
  fetchBeersFx,
  selectBeer,
} from "./store/Beers";

const Loading = () => {
  const isLoading = useStore(fetchBeersFx.pending);
  return isLoading && "Loading...";
};

const Error = () => {
  const error = useStore($beersError);
  return error && "Whoops something went wrong";
};

const ListItem = ({ beer }) => {
  return (
    <li key={beer.id}>
      <button
        className="Beer"
        onClick={() => {
          selectBeer(beer);
        }}
      >
        {beer.name}
      </button>
    </li>
  );
};

const List = () => {
  return (
    <ul className="BeerList">
      {useList($beers, (beer) => (
        <ListItem key={beer.id} beer={beer} />
      ))}
    </ul>
  );
};

const Selected = () => {
  const selected = useStore($selectedBeer);
  return (
    selected && (
      <div className="Selected">
        <p>{selected.description}</p>
        <p>{selected.abv} abv</p>
        <img height="200" src={selected.image_url} alt={selected.name} />
      </div>
    )
  );
};

export default function EffectorList() {
  useEffect(() => {
    fetchBeersFx();
  }, []);

  return (
    <>
      <h2>Effector List</h2>
      <Loading />
      <Error />
      <section className="Columns">
        <List />
        <Selected />
      </section>
    </>
  );
}

Straightaway we can see a big difference. At the top of the module, we import useStore from effector-react. It is a hook that listens for updates to our stores. We also import our stores that handle the fetching and storing of data. In the body of the component, we make the call to fetch the beers with fetchBeersFx. It is convention in Effector to prefix stores with $ and suffix effects with Fx.

We’ve broken down the component into smaller parts that are responsible for one thing. The Loading component listens to the pending event on fetchBeersFx. The Error component listens to the $beersError store. Our List component uses the useList convenience hook to pull beers from our $beers store. We pass the beers to our ListItem component. It only cares about rendering a beer and calling our selectBeer event. useList memoizes it’s output which helps to prevent unnecessary re-renders. Finally, our Selected component renders the selected beer.

Each component only does one specific thing and they subscribe to a store related to one thing. This means we’ll have much more granular updates in our components. Only the components that need to re-render will re-render. The result is also like what we could achieve with the Context API. We don’t need to pass props between components, our external stores handle the data. Context is not in use here at all. So no nested context providers and none of the potential performance pitfalls.

Overall, the code has become much more declarative. It looks like we’re describing what we want to happen much more than how it should happen. Let’s have a look at our Beers store to see how this works.

import { createEvent, createEffect, createStore } from "effector";

export const fetchBeersFx = createEffect(async () => {
  const response = await fetch("https://api.punkapi.com/v2/beers");
  if (!response.ok) {
    throw Error(response.statusText);
  }
  return response.json();
});

export const $beersError = createStore(null).on(
  fetchBeersFx.failData,
  (state, error) => error
);

export const $beers = createStore([]).on(
  fetchBeersFx.doneData,
  (state, beers) => beers
);

export const $selectedBeer = createStore(null);
export const selectBeer = createEvent();
$selectedBeer.on(selectBeer, (state, beer) => beer);

We create fetchBeeersFx using createEffect which we import from effector. The logic is the same except we’re not tracking loading or error states anymore. We give createEffect a promise and it will fire various events. The events that we are interested in are pending, failData and doneData. They will help us track those states so we don’t have to.

Below we create the $beersError store and initialize it to null. If fetchBeersFx throws an error, it fires a failData event and calls our reducer function. The reducer function calculates a new state based on the existing state and the input, the error.

When fetchBeersFx returns it fires the doneData event. Like $beersError we have a reducer function to return the next state with the beers we got from doneData.

We can select a beer by calling the selectBeer event with that beer. The $selectedBeer store listens for selectBeer events and updates with a reducer function.

Wrapping up

We used Effector to simplify our React component and make it more declarative. We completely abstracted the logic through our beer store so we use it anywhere in our React app. We could do something similar with context and some custom hooks or a library like React Async. With context, you need to set up your providers and your patterns for accessing the state. This is extra code to write and maintain and you have to deal with the potential performance issues. React Async is a great library but like context and hooks, it has one major problem. It ties us to React.

By abstracting our logic to our beers store, we’ve abstracted it from React completely. If you want to go off and experiment with web components, you can bring your Effector code with you. You can bring it into a Vue Native mobile app. If you decide to go all-in on Svelte, it handles Effector’s stores natively because they use the same primitive: Observables.

One of the oldest pieces of wisdom in UI development is to keep your view and your business logic separate. Once you separate them, the benefit is much more adaptability and flexibility. There is lots of room to explore this idea and to make our applications easier to write as a result. In future posts, I plan to look at other typical flows that Effector helps us with. Things like debouncing HTTP calls, making dependant HTTP calls and handling forms.

Thanks to everyone on the Effector Telegram for their great advice. They are super and friendly and helpful for people looking for a hand.

Thanks to Dan for his feedback on this post.