Build a Weather App with React.js, TypeScript, and Tailwind CSS
Olaleye Blessing
Posted on July 28, 2023
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.
*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
After the above is done, then you can run the following:
cd weather_app
npm start
Your default browser will be opened at http://localhost:3000/ with the following content:
Set up TailwindCSS
We first need to add Tailwind CSS as a dev dependency in our project:
npm install -D tailwindcss
Then we create a tailwind.config.js
file with the following command:
npx tailwindcss init
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: [],
}
Add the Tailwind directives to our CSS by updating index.css
file with the following code:
@tailwind base;
@tailwind components;
@tailwind utilities;
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;
App.css
should be deleted since its styling is no longer needed.
Our browser should have the following content at this point:
Other dependencies
Go ahead and install some other dependencies we will be using:
npm install @heroicons/react @tailwindcss/forms axios
-
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")],
};
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",
},
},
},
},
...
}
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
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:
- Current Weather Data to get today’s weather data
- 5 day weather forecast to get 5 days forecast
You can test these endpoints to see the sample data we will be using:
-
https://api.openweathermap.org/data/2.5/weather?q=lagos&units=metric&appid=YOUR_KEY
-
https://api.openweathermap.org/data/2.5/forecast?q=lagos&units=metric&appid=YOUR_KEY
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 {..}
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[] = [...]
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;
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;
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;
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
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;
-
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";
};
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
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
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;
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
Our page should look like this:
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);
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);
}
};
-
Comment 1
URLSearchParams encodes thequery
before adding it to theURL
. 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
-
Comment 1
Typescript provides the Partial utility type to make all fields of an interface optional. We are usingPartial
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;
}
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
-
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 thetry
andcatch
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;
-
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])
);
};
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;
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>
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;
- 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>
)
}
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>
)
}
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.
Posted on July 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.