Hooks have become a standard in modern React projects and provide a simple and easy way to manage state in function based components. As I have had more experienced developing using hooks, I have started to create a custom hook with my component function regardless of how simple the state is. Let's talk about why I always use a custom hook and look at some examples.

Table of Contents

  1. Custom Hooks and Reusabel State
  2. Custom Hooks and Code Readability
  3. Looking at an Example
  4. Back to Shared Logic
  5. Final Thoughts

Custom Hooks and Reusable State

The documentation for React Hooks lays out the basic premise of custom hooks as a way to encapsulate common/reusable state logic. Although this does provides a clean way to reuse state logic, not every component state can or should be reused due to a variety of factors. Regardless of the re-usability of a components state, creating a custom hook can still provide benefits.

Custom Hooks and Code Readability

One of the most important reasons I keep coming back to custom hooks when developing a component, of any size, is due to code legibility. The separation of state logic from the component view provides a clear separation of concerns and easier to read component functions. This separation also forces you, as the developer, to think critically about what pieces of state you absolutely need and how to handle updates. With this in mind, I want to share my style of component writing along with a custom hook for async safe state that I have found helpful.

Looking at an Example

Lets look at an example component that gets and displays the weather. This component will use API calls from Open Weather Map to get the current forecast from an entered zip code. Without the use of a custom hook, it looks something like this.

function WeatherDisplay() {
  // define state for the current weather conditions and zip code
  const [zipCode, setZipCode] = useState(null);
  const [currentWeather, setCurrentWeather] = useState(null);

  /**
   * Create the use effect for getting the current weather conditions
   * this will need to fire on changes to the zip code
   */
  useEffect(() => {
    /**
     * make API call to get the current weather for the given zip code
     */
    if (zipCode !== null) {
      var completeEndpoint = getWeatherEndpoint(zipCode);
      fetch(completeEndpoint)
        .then(response => {
          return response.json();
        })
        .then(data => {
          setCurrentWeather(data);
        })
        .then(error => {
          // do something on error
        });
    }
  }, [zipCode]);

  /**
   * create the weather display object based on the current weather state
   * if the currentWeather is null, do not display anything
   */
  var currentWeatherDisplay = null;
  if (currentWeather !== null) {
    var currentTemp = convertKelvinToFahrenheit(currentWeather.main.temp);
    var feelsLike = convertKelvinToFahrenheit(currentWeather.main.feels_like);
    var min = convertKelvinToFahrenheit(currentWeather.main.temp_min);
    var max = convertKelvinToFahrenheit(currentWeather.main.temp_max);

    // determine the icon class here in the future
    var weatherIcon = <div className="icon-sun wetherIcon"></div>;

    currentWeatherDisplay = [
      <span className="weatherHeader">Current weather for {currentWeather.name}</span>,
      <span className="weatherCurrent">
        {currentTemp}° feels like {feelsLike}°
      </span>,
      <span className="weatherRange">
        Min: {min}° Max: {max}°
      </span>,
      <div>{weatherIcon}</div>,
      <div className="weatherDescription">{currentWeather.weather[0].description}</div>
    ];
  }

  return (
    <div>
      <div>
        <span>Weather For Zipcode:</span>
        <input key="zipCodeInput" id="zipCode"></input>
        <input
          type="button"
          value="Get Weather"
          onClick={() => {
            setZipCode(document.getElementById("zipCode").value);
          }}
        ></input>
      </div>

      <div className="weatherCard">{currentWeatherDisplay}</div>
    </div>
  );
}

This is a fairly straightforward component, and utilizes both useState and useEffect to store and fetch the data from the API. Now lets look at this same component utilizing a custom hook that simply separates the useState and useEffect from the component function.

function useCurrentWeather(){
    // define state for the current weather conditions and zip code
  const [zipCode, setZipCode] = useState(null);
  const [currentWeather, setCurrentWeather] = useState(null);

  /**
   * Create the use effect for getting the current weather conditions
   * this will need to fire on changes to the zip code
   */
  useEffect(() => {
    /**
     * make API call to get the current weather for the given zip code
     */
    if (zipCode !== null) {
      var completeEndpoint = getWeatherEndpoint(zipCode);
      fetch(completeEndpoint)
        .then(response => {
          return response.json();
        })
        .then(data => {
          setCurrentWeather(data);
        })
        .then(error => {
          // do something on error
        });
    }
  }, [zipCode]);

  return [currentWeather, setZipCode];
}

function WeatherDisplay() {
  // define the useCurrentWeather state object
  const [currentWeather, setZipCode] = useCurrentWeather();

  /**
   * create the weather display object based on the current weather state
   * if the currentWeather is null, do not display anything
   */
  var currentWeatherDisplay = null;
  if (currentWeather !== null) {
    var currentTemp = convertKelvinToFahrenheit(currentWeather.main.temp);
    var feelsLike = convertKelvinToFahrenheit(currentWeather.main.feels_like);
    var min = convertKelvinToFahrenheit(currentWeather.main.temp_min);
    var max = convertKelvinToFahrenheit(currentWeather.main.temp_max);

    // determine the icon class here in the future
    var weatherIcon = <div className="icon-sun wetherIcon"></div>;

    currentWeatherDisplay = [
      <span className="weatherHeader">Current weather for {currentWeather.name}</span>,
      <span className="weatherCurrent">
        {currentTemp}° feels like {feelsLike}°
      </span>,
      <span className="weatherRange">
        Min: {min}° Max: {max}°
      </span>,
      <div>{weatherIcon}</div>,
      <div className="weatherDescription">{currentWeather.weather[0].description}</div>
    ];
  }

  return (
    <div>
      <div>
        <span>Weather For Zipcode:</span>
        <input key="zipCodeInput" id="zipCode"></input>
        <input
          type="button"
          value="Get Weather"
          onClick={() => {
            setZipCode(document.getElementById("zipCode").value);
          }}
        ></input>
      </div>

      <div className="weatherCard">{currentWeatherDisplay}</div>
    </div>
  );
}

Applying this separation of state and display makes the control easier to understand, but we can take this a step further and change the hook return to reduce the component function logic and provide a clearer separation of concerns.

function useCurrentWeather() {
  // define state for the current weather conditions and zip code
  const [zipCode, setZipCode] = useState(null);
  const [currentWeather, setCurrentWeather] = useState(null);

  /**
   * Create the use effect for getting the current weather conditions
   * this will need to fire on changes to the zip code
   */
  useEffect(() => {
    /**
     * make API call to get the current weather for the given zip code
     */
    if (zipCode !== null) {
      var completeEndpoint = getWeatherEndpoint(zipCode);
      fetch(completeEndpoint)
        .then((response) => {
          return response.json();
        })
        .then((data) => {
          setCurrentWeather(data);
        })
        .then((error) => {
          // do something on error
        });
    }
  }, [zipCode]);

  /**
   * create a modified version of the return to reduce
   * code in the WeatherDisplay componet
   */
  var currentWeatherForReturn = null;
  if (currentWeather !== null) {
    currentWeatherForReturn = {
      currentTemp: convertKelvinToFahrenheit(currentWeather.main.temp),
      feelsLike: convertKelvinToFahrenheit(currentWeather.main.feels_like),
      min: convertKelvinToFahrenheit(currentWeather.main.temp_min),
      max: convertKelvinToFahrenheit(currentWeather.main.temp_max),
      name: currentWeather.name,
      description: currentWeather.weather[0].description,
    };
  }

  return [currentWeatherForReturn, setZipCode];
}

function WeatherDisplay() {
  // define the useCurrentWeather state object
  const [currentWeather, setZipCode] = useCurrentWeather();

  /**
   * create the weather display object based on the current weather state
   * if the currentWeather is null, do not display anything
   */
  var currentWeatherDisplay = null;
  if (currentWeather !== null) {
    // determine the icon class here in the future
    var weatherIcon = <div className="icon-sun wetherIcon"></div>;

    currentWeatherDisplay = [
      <span className="weatherHeader">
        Current weather for {currentWeather.name}
      </span>,
      <span className="weatherCurrent">
        {currentWeather.currentTemp}° feels like {currentWeather.feelsLike}°
      </span>,
      <span className="weatherRange">
        Min: {currentWeather.min}° Max: {currentWeather.max}°
      </span>,
      <div>{weatherIcon}</div>,
      <div className="weatherDescription">{currentWeather.description}</div>,
    ];
  }

  return (
    <div>
      <div>
        <span>Weather For Zipcode:</span>
        <input key="zipCodeInput" id="zipCode"></input>
        <input
          type="button"
          value="Get Weather"
          onClick={() => {
            setZipCode(document.getElementById("zipCode").value);
          }}
        ></input>
      </div>

      <div className="weatherCard">{currentWeatherDisplay}</div>
    </div>
  );
}

Utilizing this custom hook, we have now separated out all state logic from the component display making it easier to understand. In addition to the legibility of the component, we have set the groundwork for easier state logic updates in the future. If we ever needed to add additional logic to the state, or change the API used to get the weather, we could do so with minimal changes to the component code. The complete version of this code can be found on the github repo.

Back to Shared Logic

When thinking about custom hooks and shared logic, we have to keep in mind that we do not need to share the entire state of a component. Custom hooks can be used to encapsulate common state-full functions for use in other hooks. One of my favorite examples of this is a custom hook for dealing with state that interacts with multiple async API calls through a single useEffect. To understand the need for this hook, lets first discuss a theoretical scenario that can cause some issues with state management, then let's look at an example hook that can create an async friendly version of useState.

Async call to get pieces of state

In this example, lets look at a custom hook that uses multiple fetch commands to get values from different APIs.

function useExampleState() {
  const [state, setState] = useState({
    value1: null,
    value2: null,
  });

  useEffect(() => {
    // make first fetch to value from the first API
    fetch(exampleApi)
      .then((res) => res.json())
      .then((data) =>
        setState({
          ...state,
          value1: data,
        })
      )
      .then((err) => console.log(err));

    // make another fetch to get value from the second API
    fetch(exampleApi2)
      .then((res) => res.json())
      .then((data) =>
        setState({
          ...state,
          value2: data,
        })
      )
      .then((err) => console.log(err));
  }, []);

  return [state];
}

Although this hook is straight forward, the end result will be a hook that behaves erratically. To understand this, lets consider the following points.

  • Each fetch command is running independently and asynchronously with unknown return timings
  • The value of "state" after the completion of either fetch will not be the expected value due to scope at time of function declaration. This will cause the second fetch return to remove the value set from the first fetch.

There are multiple ways to fix this issue, but one way we can do it is by implementing a custom hook that creates a async safe version of useState.

function useAsyncSafeState(initialState) {
    const [state, orgSetState] = useState(initialState);
    const stateRef = useRef(state);

    function setState(newState) {
        stateRef.current = newState;
        return orgSetState(newState);
    }

    return [stateRef.current, setState];
}

function useExampleState() {
  const [state, setState] = useAsyncSafeState({
    value1: null,
    value2: null,
  });

  useEffect(() => {
    // make first fetch to value from the first API
    fetch(exampleApi)
      .then((res) => res.json())
      .then((data) =>
        setState({
          ...state,
          value1: data,
        })
      )
      .then((err) => console.log(err));

    // make another fetch to get value from the second API
    fetch(exampleApi2)
      .then((res) => res.json())
      .then((data) =>
        setState({
          ...state,
          value2: data,
        })
      )
      .then((err) => console.log(err));
  }, []);

  return [state];
}

This async safe useState implements the useRef hook to keep any reference of the "state" returned from the custom hook to be current. You can read more about useRef and why this works on the React Hooks Docs.

Final Thoughts

We have gone over my thoughts on why you should always use custom hooks, as well as some thoughts regarding custom hooks for reuse. I hope this provides some inspiration and help for the design of your next hook based React component.