Build a Weather App with React.js, TypeScript, and Tailwind CSS

olaleyeblessing

Olaleye Blessing

Posted on July 28, 2023

Build a Weather App with React.js, TypeScript, and Tailwind CSS

One of the most effective ways to enhance your coding skills is to work on practical projects that challenge you and allow you to apply your knowledge in real-world scenarios.

In this tutorial, we will create a Weather App using React.js, TypeScript, and Tailwind Css. The purpose of this app is to provide users with up-to-date weather information for their desired locations. You can play with the live demo to see what we want to build and you can also look into the source code.

Weather app

*Prerequisites*

This tutorial assumes that you have a basic knowledge of HTML, CSS, JavaScript, and React.js. You don’t need to be a TypeScript expert to follow this tutorial.

Setting up the Development Environment

Setting up the development environment is the first step in building our weather app. We will use CRA to create our react project.

Make sure you have nodejs and npm installed.

Run the following command to initialize a new project:

npx create-react-app weather_app --template typescript
Enter fullscreen mode Exit fullscreen mode

After the above is done, then you can run the following:

cd weather_app
npm start
Enter fullscreen mode Exit fullscreen mode

Your default browser will be opened at http://localhost:3000/ with the following content:

Localhost

Set up TailwindCSS

We first need to add Tailwind CSS as a dev dependency in our project:

npm install -D tailwindcss
Enter fullscreen mode Exit fullscreen mode

Then we create a tailwind.config.js file with the following command:

npx tailwindcss init
Enter fullscreen mode Exit fullscreen mode

Add the paths to all of our template files in our tailwind.config.js file by replacing the content of our file with the below:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
Enter fullscreen mode Exit fullscreen mode

Add the Tailwind directives to our CSS by updating index.css file with the following code:

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

We can now test that Tailwind is working by updating the content of our App.tsx with the below code:

function App() {
  return (
    <h1 className="text-3xl font-bold underline">
      Hello world!
    </h1>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

App.css should be deleted since its styling is no longer needed.

Our browser should have the following content at this point:

Hello world in React

Other dependencies

Go ahead and install some other dependencies we will be using:

npm install @heroicons/react @tailwindcss/forms axios
Enter fullscreen mode Exit fullscreen mode
  • axios to make asynchronous requests.
  • @tailwindcss/forms to style our form.
  • @heroicons/react to display icons.

Our tailwind.config.js needs to be updated to make use of @tailwindcss/forms plugin.

/** @type {import('tailwindcss').Config} */
module.exports = {
    content: ["./src/**/*.{js,jsx,ts,tsx}"],
    theme: {
        extend: {},
    },
    plugins: [require("@tailwindcss/forms")],
};
Enter fullscreen mode Exit fullscreen mode

Finally, add the colors we will be using to the theme key in our tailwind.config.js.

module.exports = {
...
theme: {
        extend: {
            colors: {
                black: {
                    1: "#323544",
                    2: "#0000001a",
                    3: "#262936",
                },
                white: {
                    DEFAULT: "#fff",
                    1: "#bfc1c8",
                },
                blue: {
                    "1": "#009ad8",
                },
            },
        },
    },
...
}
Enter fullscreen mode Exit fullscreen mode

Get the OpenWeatherMap API Key

OpenWeatherMap provides minutely forecast, historical data, current state, and from short-term to annual forecasted weather data. We can get our weather forecasts from OpenWeatherMap by getting an API key.

Head over to **https://openweathermap.org/api** to create an account and generate an API Key.

It’s a good idea to save secret keys like our API key in a .env file so as to reduce the risk of unauthorized access.

Note: Variables saved in .env in our React application are not entirely hidden. This is because environment variables are embedded into the build, meaning anyone can view them by inspecting your app's files.

Create a .env file at the root level and store your weather key there:

// /.env
REACT_WEATHER_KEY=your_weather_key
Enter fullscreen mode Exit fullscreen mode

Notice our key name starts with REACT_APP. The reason for this naming convention is to avoid conflicts and ensure that the environment variables are only applied to your React application.

Also, add .env to your .gitignore so as not to push your secret key(s) to the public.

Get Sample data

We can now use our weather key to get sample data of what we would be expecting when we create a function to fetch our weather forecasts.

If you go through openweathermap’s documentation, you will notice we can use the:

You can test these endpoints to see the sample data we will be using:

These sample data are going to increase our development process as we won’t need to request data each time we make changes to our code.

We should create interfaces for our sample data(source code):

// src/interfaces/weather.ts
export interface IToday {...}
export interface IForecast {..}
Enter fullscreen mode Exit fullscreen mode

You can go ahead and save the sample data as constants in src/data/weather.ts as today and forecasts respectively. The list array will stand for our forecasts(source code):

// src/data/weather.ts

import { IForecast, IToday } from "../interfaces/weather";

export const today: IToday = {...}
export const forecasts: IForecast[] = [...]
Enter fullscreen mode Exit fullscreen mode

Building our Components

The best way to have a maintainable and readable react project is to have different components for different purposes. Our weather app is going to make use of different components:

  • Navbar: contains children's components:
    • Search: enables users to search for different locations.
    • Histories: saves and lists searched locations.
  • Forecasts: contains the following children components:
    • Today: displays weather data for the current day.
    • Other: displays other day's forecasts.

Icons

We will be using 3 SVG icons to illustrate different weather conditions, the SVGs code can be found in the source code(Direction, Umbrella, Wind):

// src/components/icons/Direction.tsx

const Direction = () => {}

export default Direction;

// src/components/icons/Umbrella.tsx

const Umbrella = () => {}

export default Umbrella;

// src/components/icons/Wind.tsx

const Wind = () => {}

export default Wind;
Enter fullscreen mode Exit fullscreen mode

Search.tsx

// src/components/Search.tsx
import { useState } from "react";

const Search = () => {
    const [query, setQuery] = useState("");

  return (
    <form
      className="w-full max-w-xs"
      onSubmit={(e) => {
        e.preventDefault();
        console.log("Get data");
      }}
    >
      <input
        type="search"
        name="search"
        id="search"
        aria-label="Search for city/country forecast"
        placeholder="Search for city/country forecast"
        className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
    </form>
  );
};

export default Search;
Enter fullscreen mode Exit fullscreen mode

Histories.tsx

// src/components/Histories.tsx

import { useEffect, useRef } from "react";

// Comment 1
const modalHeight = "!h-[calc(100vh-2.75rem)]"

const Histories = () => {
  // Comment 2
  const locations: string[] = JSON.parse(localStorage.getItem("locations") || "[]");
  // Comment 3
  const ulRef = useRef<HTMLDivElement>(null);

  const toggleUl = () => {
    const { current: container } = ulRef;

    if (!container) return;

    container.classList.toggle("!w-screen");
    container.classList.toggle(modalHeight);
  };

  useEffect(() => {
    const closeUIModal = () => {
      const { current: container } = ulRef;

      if (!container) return;

      container.classList.remove("!w-screen");
      container.classList.remove(modalHeight);
    }

    window.addEventListener("click", closeUIModal);

    return () => window.removeEventListener("click", closeUIModal)
  }, [])

  return (
    <div className="relative">
      <button type="button" onClick={(e) => {
        e.stopPropagation();
        toggleUl();
      }}>
        <ClockIcon className="h-6 w-6" />
      </button>
      <div
        ref={ulRef}
        className="absolute top-8 right-0 z-10 overflow-hidden h-0 w-0 bg-white transition-all duration-300 ease-in-out flex items-start justify-end pl-3 bg-opacity-5"
      >
        <div className="w-full max-w-[15rem]" onClick={e => {
          e.stopPropagation();
        }}>
          {locations.length === 0 ? (
            <p className="text-center text-gray-700 font-extrabold">
              No history
            </p>
          ) : (
            <ul className="bg-white shadow-xl rounded-lg overflow-y-auto h-full max-w-[15rem] max-h-60">
              {locations.map((location) => (
                <li
                  key={location}
                  className="flex items-center justify-between"
                >
                  <button
                    className="text-ellipsis text-left overflow-hidden whitespace-nowrap px-4 py-2 hover:bg-gray-500 transition-colors duration-150 w-full"
                    type="button"
                    onClick={() => { console.log("Get Data") }}
                  >
                    {location}
                  </button>
                </li>
              ))}
            </ul>
          )}
        </div>
      </div>
    </div>
  );
};

export default Histories;
Enter fullscreen mode Exit fullscreen mode

Comment 1: The height of the modal is the height of the screen minus the height of the navbar. This is to allow users to have the opportunity to search even if the modal is opened.

Comment 2: We are getting our location history from local storage. The components re-render each time a new location is searched. This re-rendering gives us the chance to grab the new history from the local storage.

Comment 3: A transparent modal is displayed on the page when the history icon is pressed. The useRef is used to reference the modal so that it will be easy for us to close the modal without any re-rendering.

Navbar.tsx

// src/components/Navbar.tsx

import Histories from "./Histories"
import Search from "./Search"

const Navbar = () => {
  return (
    <nav className="flex item-center justify-between">
      <div className="w-full max-w-5xl mx-auto flex item-center justify-between">
        <div>
          <a href="/" className="text-2xl font-semibold">
            Forecast
          </a>
        </div>
        <Search />
        <Histories />
      </div>
    </nav>
  )
}

export default Navbar
Enter fullscreen mode Exit fullscreen mode

Today.tsx

// src/components/forecasts/Today.tsx

import { today as data } from "../../data/weather";
import { windDirection } from "../../utils/weather";
import WeatherCondition from "./WeatherCondition";

const dateOptions: Intl.DateTimeFormatOptions = {
  weekday: "long",
  year: "numeric",
  month: "long",
  day: "numeric",
};

const timeOptions: Intl.DateTimeFormatOptions = {
  hour: "2-digit",
  minute: "2-digit",
};

const Today = () => {
  const date = new Date(data.dt * 1000);

  return (
    <div className="bg-black-1">
      <time
        dateTime={date.toString()}
        className="flex items-center justify-between bg-black-2 p-4"
      >
        <span>{date.toLocaleString(undefined, dateOptions)}</span>
        <span>{date.toLocaleTimeString(undefined, timeOptions)}</span>
      </time>
      <div className="px-4 py-12 lg:flex lg:items-center lg:justify-center lg:flex-col lg:h-[90%]">
        <h3 className="text-4xl mb-4">{data.name}</h3>
        <p>{data.weather[0].main}</p>
        <div className="mt-8 flex items-center justify-start">
          <p className="text-7xl">
            {data.main.temp}
            <sup className="relative text-[1.8rem] -top-8 font-semibold">o</sup>
            C
          </p>
          <figure className="w-16 h-16">
            <img
              src={`https://openweathermap.org/img/wn/${data.weather[0].icon}@2x.png`}
              alt="sun icon"
            />
          </figure>
        </div>
        <div className="flex items-center justify-start space-x-2 mt-4">
          <WeatherCondition icon="umbrella" value={data.main.humidity} />
          <WeatherCondition icon="wind" value={`${data.wind.speed}m/sec`} />
          <WeatherCondition
            icon="direction"
            value={windDirection(data.wind.deg)} // Comment 1
          />
        </div>
      </div>
    </div>
  );
};

export default Today;
Enter fullscreen mode Exit fullscreen mode
  • Comment 1: windDirection is a function that is used to determine the direction of the wind
// src/utils/weather.ts
export const windDirection = (deg: number) => {
    if (deg === 0) return "N";

    if (deg > 0 && deg < 90) return "NE";

    if (deg === 90) return "E";

    if (deg > 90 && deg < 180) return "SE";

    if (deg === 180) return "S";

    if (deg > 180 && deg < 270) return "SW";

    if (deg === 270) return "W";

    if (deg > 270 && deg < 360) return "NW";

    return "N";
};
Enter fullscreen mode Exit fullscreen mode

WeatherCondition.tsx

// src/components/forecasts/WeatherCondition.tsx

import { FC } from "react";
import Umbrella from "../icons/Umbrella";
import Wind from "../icons/Wind";
import Direction from "../icons/Direction";

const Icons = {
  "umbrella": <Umbrella />,
  "wind": <Wind />,
  "direction": <Direction />
}

interface Props {
  value: string | number;
  icon: keyof typeof Icons;
}

const WeatherCondition: FC<Props> = ({ value, icon }) => {
  return (
    <div className="flex items-center justify-start">
      <figure className="w-5 h-5 mr-2">
        {Icons[icon]}
      </figure>
      <p className="font-semibold">{value}</p>
    </div>
  )
}

export default WeatherCondition
Enter fullscreen mode Exit fullscreen mode

Other.tsx

// src/components/forecasts/Other.tsx

import { forecasts } from "../../data/weather";

const dateOptions: Intl.DateTimeFormatOptions = {
  weekday: "long",
  year: "numeric",
  month: "long",
  day: "numeric",
};

const Other = () => {
  return (
    <ul className="grid grid-cols-[repeat(auto-fit,minmax(20rem,1fr))] lg:flex-grow lg:flex-shrink">
      {forecasts.map((forecast, key) => {
        const date = new Date(forecast.dt * 1000);
        const day = date.toLocaleDateString(undefined, dateOptions).split(", ")[0];

        return <li key={key} className="bg-black-3 even:bg-black-1 flex flex-col items-center justify-center text-center">
          <time className="bg-black-2 py-4 block w-full">{day}</time>
          <figure className="mt-8">
            <img
              src={`https://openweathermap.org/img/wn/${forecast.weather[0].icon}@2x.png`}
              alt="sun icon"
            />
          </figure>
          <p className="text-4xl mb-8 mt-4">
            {forecast.main.temp}
            <sup className="relative text-[1.8rem] -top-8 font-semibold">o</sup>
            C
          </p>
        </li>
      })}
    </ul>
  )
}

export default Other
Enter fullscreen mode Exit fullscreen mode

Forecasts.tsx

// src/components/forecasts/Index.tsx

import Today from "./Today";
import Other from "./Other";

const Index = () => {
  return (
    <section className="text-white-1 lg:flex">
      <Today />
      <Other />
    </section>
  );
};

export default Index;
Enter fullscreen mode Exit fullscreen mode

Now that our components are ready, we can go ahead and update our App.tsx:

// src/App.tsx

import Navbar from "./components/Navbar";
import Forecasts from "./components/forecasts/Index";

const App = () => {
  return (
    <div className="px-4 py-3 min-h-screen bg-gray-200">
      <Navbar />
      <main className="mt-8 max-w-5xl mx-auto">
        <Forecasts />
      </main>
    </div>
  );
}

export default App
Enter fullscreen mode Exit fullscreen mode

Our page should look like this:

Static weather forecast

Implement Functionalities

We need to replace our static data with real data. From our current page, you would notice we have more forecasts than we need. We have different forecasts for each day. We need to filter the forecasts in such a way that we would only display the forecasts at 00:00 of each day.

// src/utils/weather.ts
import { IForecast } from "../interfaces/weather";

export const filterForecasts = (data: IForecast[]) =>
    data.filter((list) => list.dt_txt.indexOf("00:00:00") != -1);
Enter fullscreen mode Exit fullscreen mode

Next, we would implement the function that fetches the real data.

// src/services/weather.ts

import axios, { isAxiosError } from "axios";
import { IForecast, IToday } from "../interfaces/weather";
import { filterForecasts } from "../utils/weather";

const WEATHER_KEY = import.meta.env.VITE_WEATHER_KEY;

interface Data {
    today: IToday;
    forecasts: IForecast[];
}

export const getForecasts = async (query: string) => {
    const baseUrl = "https://api.openweathermap.org/data/2.5";
    // Comment 1
    const searchParams = new URLSearchParams({
        q: query,
        units: "metric",
        appid: WEATHER_KEY,
    }).toString();

    const urls = [
        `${baseUrl}/weather?${searchParams}`,
        `${baseUrl}/forecast?${searchParams}`,
    ];

    try {
        const requests = urls.map((url) => axios.get(url));

        // Comment 2
        const responses = await Promise.all(requests);

        const [today, forecasts] = responses.map((response) => response.data);

        const data: Data = {
            today,
            forecasts: filterForecasts(forecasts.list),
        };

        return data;
    } catch (error) {
        let message = "Unknown error";

        if (isAxiosError(error)) {
            message =
                error.message === "Network Error"
                    ? "Please! Check your internet connection"
                    : "Location not found";
        }

        throw new Error(message);
    }
};
Enter fullscreen mode Exit fullscreen mode
  • Comment 1 URLSearchParams encodes the query before adding it to the URL. You can read more about it in my article: Create Dynamic URLs with URL Constructor in JavaScript.
  • Comment 2 Promise.all executes our requests in parallel and returns the response of each request. It returns an error for the whole request if any of the requests fails. We used Promise.all because the result of one doesn’t depend on the other.

We will start updating our components from the base, App.tsx

App.tsx

// src/App.tsx

import { useState } from "react";
import Navbar from "./components/Navbar";
import Forecasts from "./components/forecasts/Index";
import { IFetchWeather } from "./interfaces/weather";

const defaultWeather: IFetchWeather = {
  loading: false,
  data: null,
  error: null,
};

const App = () => {
  const [forecasts, setForecasts] = useState<IFetchWeather>(defaultWeather);

    // Comment 1
  const handleSetForecasts = (forecasts: Partial<IFetchWeather>) => {
    setForecasts((prev) => ({ ...prev, ...forecasts }));
  }

  return (
    <div className="...">
      <Navbar handleSetForecasts={handleSetForecasts} />
      <main className="...">
        <Forecasts {...forecasts} />
      </main>
    </div>
  );
}

export default App
Enter fullscreen mode Exit fullscreen mode
  • Comment 1 Typescript provides the Partial utility type to make all fields of an interface optional. We are using Partial to make sure not all fields are compulsory as we won’t be updating all keys at once.

Our interfaces need to be updated to have IFetchWeather

// src/interfaces/weather.ts

export interface IFetchWeather {
    loading: boolean;
    data: { today: IToday; forecasts: IForecast[] } | null;
    error: null | string;
}
Enter fullscreen mode Exit fullscreen mode

Navbar.tsx

// src/components/Navbar.tsx

import { FC } from "react";
import { getForecasts } from "../services/weather";
import { IFetchWeather } from "../interfaces/weather";
import Histories from "./Histories"
import Search from "./Search"

interface Props {
  handleSetForecasts: (forecasts: Partial<IFetchWeather>) => void;
}

const Navbar: FC<Props> = ({ handleSetForecasts }) => {
    // Comment 1
  const fetchForecasts = async (query: string) => {
    try {
      handleSetForecasts({ loading: true, data: null, error: null });
      const data = await getForecasts(query);
      handleSetForecasts({ data });
    } catch (error: any) {
      handleSetForecasts({ error: error.message });
    } finally {
            // Commnet 2
      handleSetForecasts({ loading: false });
    }
  };

  return (
    <nav className="...">
      <div className="...">
        ...
                ...
        <Search fetchForecasts={fetchForecasts} />
        <Histories fetchForecasts={fetchForecasts} />
      </div>
    </nav>
  )
}

export default Navbar
Enter fullscreen mode Exit fullscreen mode
  • Comment 1 fetchForecasts resets the forecasts state before fetching new data and then updates the forecasts state depending on the result.
  • Comment 2 Finally runs whether there is an error or data. This is the best place to have our cleanup instead of repeating the same code in the try and catch blocks.

Search.tsx

// src/components/Search.tsx

import { FC, useState } from "react";
import { persistLocation } from "../utils/weather";

interface Props {
  fetchForecasts: (query: string) => Promise<void>;
}

const Search: FC<Props> = ({ fetchForecasts }) => {
  const [query, setQuery] = useState("");

  return (
    <form
      className="w-full max-w-xs"
      onSubmit={(e) => {
        e.preventDefault();
                // Comment 1
        fetchForecasts(query);
                persistLocation(query);
      }}
    >
      ...
    </form>
  );
};

export default Search;
Enter fullscreen mode Exit fullscreen mode
  • Comment 1 We fetch new data each time the user presses enter.

persistLocation function updates the local storage each time a new location is searched:

// src/utils/weather.tsx

export const persistLocation = (location: string) => {
    const locations = localStorage.getItem("locations");

    if (!locations) {
        localStorage.setItem("locations", JSON.stringify([location]));
        return;
    }

    const parsedLocations: string[] = JSON.parse(locations);

    if (parsedLocations.includes(location)) return;

    localStorage.setItem(
        "locations",
        JSON.stringify([...parsedLocations, location])
    );
};
Enter fullscreen mode Exit fullscreen mode

Histories.tsx

// src/components/Histories.tsx

import { FC, useEffect, useRef } from "react";
import { ClockIcon } from "@heroicons/react/20/solid";

const modalHeight = "!h-[calc(100vh-2.75rem)]"

interface Props {
  fetchForecasts: (query: string) => Promise<void>;
}

const Histories: FC<Props> = ({ fetchForecasts }) => {
  // ...

  return (
    <div className="relative">
      // ....
    </div>
  );
};

export default Histories;
Enter fullscreen mode Exit fullscreen mode

In the same file, change the location button to fetch new data when a location is clicked:

// src/components/Histories.tsx

<button
    className="..."
  type="button"
  onClick={() => fetchForecasts(location)}
 >
  {location}
 </button>
Enter fullscreen mode Exit fullscreen mode

Forecasts.tsx

import Today from "./Today";
import Other from "./Other";
import { IFetchWeather } from "../../interfaces/weather";
import { FC } from "react";

const Index: FC<IFetchWeather> = ({ data, error, loading }) => {
  if (loading) return <p>Loading...</p>;

  if (error) return <p className="font-semibold text-lg text-red-600">{error}</p>;

  if (!data) return null;

  return (
    <section className="text-white-1 lg:flex">
      <Today data={data.today} />
      <Other forecasts={data.forecasts} />
    </section>
  );
};

export default Index;
Enter fullscreen mode Exit fullscreen mode
  • This component has been updated to display different items based on if the data is still loading if there is an error or if data is available.

Today.tsx

// src/components/forecasts/Today.tsx

import { FC } from "react";
import { windDirection } from "../../utils/weather";
import WeatherCondition from "./WeatherCondition";
import { IToday } from "../../interfaces/weather";

const dateOptions: Intl.DateTimeFormatOptions = {
  weekday: "long",
  minute: "2-digit",
};

const Today: FC<{ data: IToday }> = ({ data }) => {
  const date = new Date(data.dt * 1000);

  return (
    <div className="bg-black-1">
            ...
        </div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Other.tsx

// src/components/forecasts/Other.tsx

import { FC } from "react";
import { IForecast } from "../../interfaces/weather";

interface Props {
  forecasts: IForecast[]
}

const dateOptions: Intl.DateTimeFormatOptions = {
  weekday: "long",
  day: "numeric",
};

const Other: FC<Props> = ({ forecasts }) => {
  return (
    <ul className="..">
            ...
        </ul>
    )
}
Enter fullscreen mode Exit fullscreen mode

We can check our browser now to see how this works.

Conclusion

This comes to the end of our weather app. The app could still be improved by:

  • preventing the user from searching for an empty query.
  • making sure persisted/saved locations are case-insensitive.
  • deleting history.
💖 💪 🙅 🚩
olaleyeblessing
Olaleye Blessing

Posted on July 28, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related