Phase 3: Weather Component

Understanding the Problem

In this phase, we'll refactor the Weather component from a class component to a function component. This introduces a significant new challenge: handling lifecycle methods with hooks. The Weather component:

This is a common pattern in React applications—fetching data when a component mounts and then displaying that data. In function components, we'll use the useEffect hook to replicate this behavior.

Setting Up the Weather API Key

Before we start refactoring, we need to make sure we have access to a weather API key. The application uses the OpenWeatherMap API, which requires an API key for authentication.

  1. Create a .env file in the root directory of your project
  2. Add the following line to the file:
    VITE_WEATHER_API=b65b43cc09af164f099fe5a807d56972
  3. Make sure the .env file is included in your .gitignore to avoid exposing your API key
  4. If you encounter a fetch limit, you can try one of the alternative keys listed in the README

In Vite (the build tool this project uses), environment variables are accessed using import.meta.env instead of process.env that you might be familiar with from Create React App.

George Polya's Problem-Solving Method

Step 1: Understand the Problem

Let's analyze the original Weather class component to understand its functionality:


// Weather.jsx
import React from 'react';
import { toQueryString } from '../utils';

class Weather extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        weather: null
      };
    }
    
    componentDidMount() {
      navigator.geolocation?.getCurrentPosition(
        this.pollWeather,
        (err) => console.log(err),
        { timeout: 10000 }
      );
    }

    pollWeather = async (location) => {
      let url = 'http://api.openweathermap.org/data/2.5/weather?';

      /* Remember that it's unsafe to expose your API key. (Note that pushing
      files that include your key to Github will expose your key!) In
      production, you would definitely save your key in an environment variable,
      so do that here. Since this project runs in your local environment
      (localhost), save your key as an environment variable in a .env file in
      the root directory of your app. You can then access the key here as
      "process.env.". Make sure to .gitignore your .env file!
      Also remember to restart your server (i.e., re-run "npm run dev") whenever
      you change your .env file. */
      const apiKey = '???';

      const params = {
        lat: location.coords.latitude,
        lon: location.coords.longitude,
        appid: apiKey
      };
      
      url += toQueryString(params);

      const res = await fetch(url);
      if (res.ok) {
        const weather = await res.json();
        this.setState({ weather });
      }
      else {
        alert ("Check Weather API key!")
      }
    }

  render() {
    const weather = this.state.weather;
    let content = <div className='loading'>loading weather...</div>;
    
    if (weather) {
      const temp = (weather.main.temp - 273.15) * 1.8 + 32;
      content = (
        <div>
          <p>{weather.name}</p>
          <p>{temp.toFixed(1)} degrees</p>
        </div>
      );
    }
    else {
      content = (
        <div>
          Weather is currently unavailable. (Are Location Services enabled?) 
        </div>
      )
    }

    return (
      <section className="weather-section">
        <h1>Weather</h1>
        <div className='weather'>
          {content}
        </div>
      </section>
    );
  }
}

export default Weather;
            

Analyzing this code, we can see that:

Step 2: Devise a Plan

  1. Convert the class declaration to a function declaration
  2. Replace this.state.weather with the useState hook
  3. Replace componentDidMount with the useEffect hook
  4. Move the pollWeather function inside the useEffect callback
  5. Update the API key to use import.meta.env.VITE_WEATHER_API
  6. Update references to this.state and remove the render method

Step 3: Carry Out the Plan

1. Component Declaration and State

We'll start by converting the class declaration to a function and setting up the state with useState:


// From
class Weather extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      weather: null
    };
  }
  // ...
}

// To
function Weather() {
  const [weather, setWeather] = useState(null);
  // ...
}
            

Don't forget to import the necessary hooks:

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

2. Converting componentDidMount with useEffect

The key new concept here is using useEffect to replace the componentDidMount lifecycle method. The useEffect hook takes two arguments:

When you pass an empty array [] as the second argument, the effect will only run once after the component mounts, which is equivalent to componentDidMount:


// From
componentDidMount() {
  navigator.geolocation?.getCurrentPosition(
    this.pollWeather,
    (err) => console.log(err),
    { timeout: 10000 }
  );
}

// To
useEffect(() => {
  navigator.geolocation?.getCurrentPosition(
    // pollWeather will be defined inside this callback
    // ...
    (err) => console.log(err),
    { timeout: 10000 }
  );
}, []); // Empty array means "run once after mount"
            

3. Moving pollWeather Inside useEffect

In function components, it's common to define helper functions inside the effects that use them. This keeps the code organized and avoids issues with stale closures:


useEffect(() => {
  // Define pollWeather inside the effect
  const pollWeather = async (location) => {
    let url = 'http://api.openweathermap.org/data/2.5/weather?';
    
    // Use environment variable for API key
    const apiKey = import.meta.env.VITE_WEATHER_API;
    
    const params = {
      lat: location.coords.latitude,
      lon: location.coords.longitude,
      appid: apiKey
    };
    
    url += toQueryString(params);
    
    try {
      const res = await fetch(url);
      if (res.ok) {
        const weatherData = await res.json();
        setWeather(weatherData);
      } else {
        alert("Check Weather API key!");
      }
    } catch (error) {
      console.error("Failed to fetch weather:", error);
    }
  };
  
  navigator.geolocation?.getCurrentPosition(
    pollWeather,
    (err) => console.log(err),
    { timeout: 10000 }
  );
}, []);
            

Notice several improvements in our implementation:

4. Updating the Render Logic

Now we'll update the render logic to use the weather state variable directly:


// Content rendering logic based on weather state
let content = <div className='loading'>loading weather...</div>;

if (weather) {
  const temp = (weather.main.temp - 273.15) * 1.8 + 32;
  content = (
    <div>
      <p>{weather.name}</p>
      <p>{temp.toFixed(1)} degrees</p>
    </div>
  );
} else {
  content = (
    <div>
      Weather is currently unavailable. (Are Location Services enabled?) 
    </div>
  );
}

// Return JSX directly from the function component
return (
  <section className="weather-section">
    <h1>Weather</h1>
    <div className='weather'>
      {content}
    </div>
  </section>
);
            

5. Putting It All Together

Here's the complete refactored Weather component:


// Weather.jsx (Refactored)
import React, { useState, useEffect } from 'react';
import { toQueryString } from '../utils';

function Weather() {
  // Replace this.state with useState
  const [weather, setWeather] = useState(null);
  
  // Replace componentDidMount with useEffect
  useEffect(() => {
    // Define pollWeather inside the effect
    const pollWeather = async (location) => {
      let url = 'http://api.openweathermap.org/data/2.5/weather?';
      
      // Use environment variable for API key
      const apiKey = import.meta.env.VITE_WEATHER_API;
      
      const params = {
        lat: location.coords.latitude,
        lon: location.coords.longitude,
        appid: apiKey
      };
      
      url += toQueryString(params);
      
      try {
        const res = await fetch(url);
        if (res.ok) {
          const weatherData = await res.json();
          setWeather(weatherData);
        } else {
          alert("Check Weather API key!");
        }
      } catch (error) {
        console.error("Failed to fetch weather:", error);
      }
    };
    
    navigator.geolocation?.getCurrentPosition(
      pollWeather,
      (err) => console.log(err),
      { timeout: 10000 }
    );
  }, []); // Empty dependency array means run once on mount
  
  // Determine content based on weather state
  let content = <div className='loading'>loading weather...</div>;
  
  if (weather) {
    const temp = (weather.main.temp - 273.15) * 1.8 + 32;
    content = (
      <div>
        <p>{weather.name}</p>
        <p>{temp.toFixed(1)} degrees</p>
      </div>
    );
  } else {
    content = (
      <div>
        Weather is currently unavailable. (Are Location Services enabled?) 
      </div>
    );
  }
  
  // Return what was previously in the render method
  return (
    <section className="weather-section">
      <h1>Weather</h1>
      <div className='weather'>
        {content}
      </div>
    </section>
  );
}

export default Weather;
            

Step 4: Look Back and Review

Let's verify that our refactored component maintains the same functionality as the original:

Let's run the tests to confirm that everything works as expected:

npm test

The tests for the Weather component should pass, confirming that our refactoring was successful.

Understanding useEffect

What is useEffect?

The useEffect hook is one of the most powerful and important hooks in React. It lets you perform side effects in function components. Side effects can include:

useEffect takes two arguments:

  1. A function that contains the code to run (the "effect")
  2. An optional array of dependencies that determines when the effect should re-run

useEffect and Lifecycle Methods

The useEffect hook can be used to replicate the behavior of various lifecycle methods from class components:

Lifecycle Method useEffect Equivalent
componentDidMount useEffect(() => { ... }, [])
componentDidUpdate useEffect(() => { ... }, [dependencies])
componentWillUnmount useEffect(() => { return () => { ... }; }, [...])

In our Weather component, we used useEffect with an empty dependency array to replicate componentDidMount.

Dependency Array

The second argument to useEffect is an array of dependencies. This array controls when the effect runs:

In our Weather component, we used an empty dependency array because we only want to fetch the weather data once when the component mounts, not on every re-render or state change.

Cleanup Function

Effects can return a cleanup function that runs before the component unmounts or before the effect runs again:

useEffect(() => {
  // Effect code here...
  
  // Return a cleanup function
  return () => {
    // Cleanup code here...
  };
}, [dependencies]);

This is useful for cleaning up subscriptions, timers, or event listeners to prevent memory leaks. We didn't need a cleanup function in our Weather component, but we'll see this pattern in later examples.

Common Patterns for Data Fetching with useEffect

Basic Pattern

The pattern we used in our Weather component is a common approach for data fetching:

function MyComponent() {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch('https://api.example.com/data');
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const result = await response.json();
        setData(result);
      } catch (error) {
        setError(error);
      } finally {
        setLoading(false);
      }
    };
    
    fetchData();
  }, []); // Empty array means run once after mount
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!data) return <div>No data available</div>;
  
  return (
    <div>
      {/* Render data here */}
    </div>
  );
}

This pattern includes loading and error states for a better user experience.

Fetching Data with Parameters

If you need to fetch data based on props or state, you should include those values in the dependency array:

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    if (!query) return; // Skip the effect if query is empty
    
    const fetchResults = async () => {
      const response = await fetch(`https://api.example.com/search?q=${query}`);
      const data = await response.json();
      setResults(data);
    };
    
    fetchResults();
  }, [query]); // Re-run the effect when query changes
  
  return (
    <div>
      {/* Render results here */}
    </div>
  );
}

Cancelling Requests

For long-running requests, it's important to handle cancellation to avoid setting state on unmounted components or race conditions:

useEffect(() => {
  let isMounted = true;
  
  const fetchData = async () => {
    const response = await fetch('https://api.example.com/data');
    const data = await response.json();
    
    if (isMounted) {
      setData(data); // Only update state if component is still mounted
    }
  };
  
  fetchData();
  
  // Cleanup function
  return () => {
    isMounted = false; // Set flag to false when component unmounts
  };
}, []);

This pattern prevents the "Can't perform a React state update on an unmounted component" warning.

Real-World Applications

When to Use This Pattern

The pattern we've implemented—fetching data when a component mounts—is extremely common in web applications and can be found in many scenarios:

Best Practices

Extensions

The basic pattern we've implemented can be extended in many ways: