Skip to main content

πŸ› οΈ Weekend Project: Frontend Frameworks & State

This is the capstone for the module: a hands-on weekend build where you turn everything you learned about components, state, and data flow into a real, working Weather Dashboard. You'll work through it in clear milestones, tick off a checklist as you go, and measure your result against a "what good looks like" bar.

🎯 Learning Objectives

By the end of this project, you will be able to:

  • Scaffold a single-page app in a modern framework (React, Vue, or Svelte) and structure it into clear components
  • Design a state model β€” decide what is local component state, what is shared app state, and what is server data
  • Fetch, cache, and render live data from a public API with proper loading and error states
  • Persist user choices (favorite locations, units) so they survive a page reload
  • Self-assess your build against a concrete quality checklist

Estimated Time: 6–10 hours (a focused weekend)  β€’  Difficulty: Intermediate

Hands-on: This whole lesson is the hands-on. You ship a running app by Sunday night.

In This Lesson

What You're Building

You'll build a Weather Dashboard: a small single-page app that lets a user search for a city, see current conditions and a short forecast, and pin a few favorite locations that are remembered between visits. It's deliberately modest in scope but rich in the skills that matter β€” component composition, state management, asynchronous data, and persistence.

Pick one framework you want to practice. The concepts are identical across all three; only the syntax differs.

πŸ“– Choose your framework

React β€” hooks (useState, useEffect), plus a Context or a small store for shared state. Scaffold with npm create vite@latest -- --template react.

Vue 3 β€” the Composition API (ref, computed, watch) and a Pinia store. Scaffold with npm create vue@latest.

Svelte β€” reactive $state/stores and dead-simple bindings. Scaffold with npm create svelte@latest.

The finished feature set is small and testable:

  • Search a city by name and show current temperature, conditions, humidity, and wind.
  • Show a compact multi-day forecast.
  • Save and remove favorite cities; favorites survive a reload.
  • Toggle Β°C / Β°F, and remember that choice too.
  • Handle the boring-but-critical states: loading, empty, and error.

πŸ’‘ Get a free API key first

Sign up for a free key at OpenWeatherMap (or Open-Meteo, which needs no key at all). Keep the key out of your committed code β€” put it in a .env file and reference it via your bundler's env system (e.g. import.meta.env.VITE_WEATHER_KEY in Vite).

How to Approach It (Polya's Method)

Rather than diving straight into code, borrow a four-step problem-solving loop from mathematician George PΓ³lya. It keeps a weekend build from turning into a weekend of thrashing.

flowchart LR A[1. Understand
the problem] --> B[2. Devise
a plan] B --> C[3. Execute
the plan] C --> D[4. Review
& extend] D -->|new insight| A
StepFor this project, that means…
UnderstandList the exact user actions and the data each screen needs. Write them down before opening the editor.
PlanSketch the component tree and the state model (next section). Decide the milestones and their order.
ExecuteBuild one vertical slice at a time β€” search working end-to-end before you touch favorites.
ReviewRun through the checklist, fix the gaps, then pick one stretch goal.
⚠️ Resist the urge to build everything at once. A vertical slice β€” search box β†’ API call β†’ rendered card β€” that works end-to-end teaches you more in an hour than a half-finished UI for every feature does in a day.

Design the State First

State management is the real subject of this module, so plan it deliberately. The single most useful question you can ask about any piece of data is: who owns it, and who needs to see it? That answer tells you where it lives.

Three kinds of state in the Weather Dashboard Local UI state lives inside a component, shared app state lives in a store used by many components, and server data is fetched and cached from the weather API. Local UI state (one component) search input text dropdown open? form validation useState / ref Shared app state (a store) favorite cities unit (Β°C / Β°F) active city Context Β· Pinia Β· store Server data (fetched + cached) current weather forecast loading / error fetch + cache map
Figure 1 β€” Sort every value into one of three buckets. Keep transient UI state local; only lift a value into the shared store when more than one component truly needs it.

⚠️ The classic beginner mistake

Dumping everything into a global store "just in case." Global state you don't need makes components harder to reuse and re-renders harder to reason about. Start local; lift up only when sharing forces you to.

Here's a reasonable shape for the shared store β€” notice it holds user choices and the active selection, but the fetched weather is cached separately, keyed by city:

// The shared, persisted slice of state
const appState = {
  favorites: ['London', 'Tokyo'], // saved cities
  unit: 'metric',                 // 'metric' (Β°C) or 'imperial' (Β°F)
  activeCity: 'London',           // which city the dashboard shows
};

// Server data is cached separately, keyed by "city:unit"
// so switching units or revisiting a city can reuse a recent result.
const weatherCache = new Map(); // 'London:metric' -> { data, fetchedAt }

The Milestones

Work top to bottom. Each milestone leaves you with something that runs β€” commit at the end of every one so you always have a working fallback.

flowchart TD M0[M0 Β· Scaffold + component tree] --> M1[M1 Β· Search one city end-to-end] M1 --> M2[M2 Β· Loading & error states] M2 --> M3[M3 Β· Shared store: favorites + unit] M3 --> M4[M4 Β· Persist to localStorage] M4 --> M5[M5 Β· Forecast + polish]

Milestone 0 β€” Scaffold & component tree

Create the project, delete the boilerplate, and stub out empty components so the tree is visible. A workable breakdown:

App
β”œβ”€β”€ SearchBar         (local state: input text)
β”œβ”€β”€ UnitToggle        (reads/writes shared: unit)
β”œβ”€β”€ FavoritesList     (reads shared: favorites; click sets activeCity)
β”‚   └── FavoriteItem
└── WeatherPanel      (reads shared: activeCity, unit; fetches server data)
    β”œβ”€β”€ CurrentCard
    └── Forecast
        └── ForecastDay

Milestone 1 β€” Search one city, end-to-end

Wire the search box to the API and render a current-conditions card. Don't worry about favorites or units yet β€” just prove the data pipeline works for a single hard-coded unit.

Milestone 2 β€” Loading & error states

Every fetch has three outcomes, not one. Show a spinner or skeleton while loading, a friendly message on failure (bad city name, network down), and the data on success. This is where amateur apps and solid apps diverge.

Milestone 3 β€” Shared store: favorites & unit

Introduce your store. Move favorites, unit, and activeCity into it. Now clicking a favorite updates the panel, and the unit toggle re-renders everything consistently.

Milestone 4 β€” Persist to localStorage

Read the store's initial value from localStorage on startup, and write back whenever favorites or unit change. Reload the page β€” your choices should still be there.

Milestone 5 β€” Forecast & polish

Add the multi-day forecast, tidy the layout so it's responsive, add icons, and make sure keyboard users can operate the search and favorites.

Worked Example: The Data Layer

The trickiest, most reusable piece is the fetch-and-cache function. Building it once, cleanly, makes every framework's component code trivial. Here it is framework-agnostic β€” plain modern JavaScript you can import anywhere.

// src/lib/weather.js
const BASE = 'https://api.openweathermap.org/data/2.5';
const KEY = import.meta.env.VITE_WEATHER_KEY;
const cache = new Map();          // 'city:unit' -> { data, fetchedAt }
const MAX_AGE = 10 * 60 * 1000;   // 10 minutes

export async function getWeather(city, unit = 'metric') {
  const key = `${city.toLowerCase()}:${unit}`;
  const cached = cache.get(key);
  if (cached && Date.now() - cached.fetchedAt < MAX_AGE) {
    return cached.data;           // fresh enough β€” skip the network
  }

  const url = `${BASE}/weather?q=${encodeURIComponent(city)}`
            + `&units=${unit}&appid=${KEY}`;
  const res = await fetch(url);

  if (res.status === 404) throw new Error(`City "${city}" not found.`);
  if (!res.ok) throw new Error(`Weather service error (${res.status}).`);

  const data = await res.json();
  cache.set(key, { data, fetchedAt: Date.now() });
  return data;
}

Now a React component that consumes it stays small β€” all the messy caching lives elsewhere:

// src/components/WeatherPanel.jsx
import { useState, useEffect } from 'react';
import { getWeather } from '../lib/weather';

export default function WeatherPanel({ city, unit }) {
  const [state, setState] = useState({ status: 'idle', data: null, error: null });

  useEffect(() => {
    if (!city) return;
    let cancelled = false;
    setState({ status: 'loading', data: null, error: null });

    getWeather(city, unit)
      .then((data) => { if (!cancelled) setState({ status: 'success', data, error: null }); })
      .catch((err) => { if (!cancelled) setState({ status: 'error', data: null, error: err.message }); });

    return () => { cancelled = true; };   // ignore stale responses
  }, [city, unit]);

  if (state.status === 'loading') return <p role="status">Loading {city}…</p>;
  if (state.status === 'error')   return <p role="alert">{state.error}</p>;
  if (!state.data)                return <p>Search for a city to begin.</p>;

  const { name, main, weather } = state.data;
  const symbol = unit === 'metric' ? 'Β°C' : 'Β°F';
  return (
    <article className="current-card">
      <h3>{name}</h3>
      <p className="temp">{Math.round(main.temp)}{symbol}</p>
      <p>{weather[0].description} Β· humidity {main.humidity}%</p>
    </article>
  );
}

πŸ’‘ Why the cancelled flag matters

If a user searches "Paris" then quickly "Tokyo", two requests are in flight. Without the cleanup flag, a slow Paris response could arrive last and overwrite Tokyo β€” a classic race condition. The cleanup function ignores any response from an effect that's already been superseded.

And the persistence helper for Milestone 4 β€” small, and reused for favorites and unit alike:

// src/lib/storage.js
export function load(key, fallback) {
  try {
    const raw = localStorage.getItem(key);
    return raw ? JSON.parse(raw) : fallback;
  } catch {
    return fallback;   // corrupted or unavailable storage β€” degrade gracefully
  }
}

export function save(key, value) {
  try {
    localStorage.setItem(key, JSON.stringify(value));
  } catch {
    /* storage full or blocked β€” ignore, app still works in-memory */
  }
}

Build Checklist

Tick these off as you go. If every box is checked, you've hit the core bar for the project.

βœ… Core requirements

  • Project scaffolds and runs with a single npm run dev.
  • Searching a valid city shows current temperature, conditions, humidity, and wind.
  • An invalid city shows a clear error message β€” the app does not crash or hang.
  • A loading indicator appears while a request is in flight.
  • Favorites can be added and removed; clicking one loads its weather.
  • The Β°C / Β°F toggle updates all displayed temperatures consistently.
  • Favorites and unit choice survive a full page reload.
  • The API key lives in .env, not in committed source.
  • Layout is usable on a phone-width screen.
  • Search and favorites are operable with the keyboard alone.

⚠️ Common gaps to double-check

  • The empty first-load state (no city selected yet) β€” does it say something helpful?
  • Rapid searches β€” does the last one always win? (See the cancelled flag.)
  • Adding a city that's already a favorite β€” is it de-duplicated?
  • Is temperature rounded, or does it show 17.34Β°C?

What Good Looks Like

Passing the checklist means it works. These qualities are what separate a "works on my machine" demo from something you'd be glad to show an interviewer.

DimensionJust okayWhat good looks like
State Everything in one giant global object. Local state stays local; only genuinely shared values live in the store; server data is cached separately.
Async Only the happy path is handled. Loading, empty, and error states are all deliberate; races can't corrupt the UI.
Components One 400-line component. Small, single-purpose components with clear props; data fetching separated from presentation.
Resilience Crashes on bad input or storage errors. Degrades gracefully β€” corrupted storage, offline, and bad city names are all survivable.
Accessibility Mouse-only, no status messaging. Keyboard operable, role="status"/role="alert" for async feedback, sufficient contrast.
Repo No README, secrets committed. A README explaining choices, .env.example committed instead of the real key, tidy commit history.

πŸ’‘ Self-review ritual

Before you call it done, open the app in a fresh browser profile, disable your network in DevTools, feed it a nonsense city name, and reload it twice. If it stays calm and informative through all of that, it's good.

Stretch Goals

Finished early, or want to push the state-management muscle harder? Pick one β€” depth beats breadth.

  • Geolocation: on first load, ask permission and default the dashboard to the user's current location.
  • Derived state: add a "feels like vs. actual" badge computed from the data rather than stored β€” practice computed/useMemo.
  • Optimistic UI: when adding a favorite, show it immediately and roll back only if something fails.
  • Offline-first: register a service worker so the last-viewed cities render even with no network (a callback to the previous lesson).
  • Testing: write a couple of unit tests for getWeather's cache and error handling.
  • Deploy: ship it to Netlify or Vercel and put the live URL in your README.

πŸ‹οΈ Reflection exercise

Objective: Cement the state-design thinking, not just the code.

After you finish, write a short paragraph in your README answering: which values did you keep local, which did you lift into the store, and why? Name one value you were tempted to make global but didn't need to.

πŸ’‘ Hint

The search input text is the classic "tempted to globalize but shouldn't." Only WeatherPanel needs the result of a search (the active city), not the raw keystrokes.

βœ… Sample answer

"Search text and the favorites-dropdown-open flag stayed local to their components β€” nothing else cares about them. favorites, unit, and activeCity went into the store because the panel, the toggle, and the favorites list all read or write them. Fetched weather isn't in the store at all; it's cached by city so it can be refetched independently of user choices. I was tempted to put the search text in the store to 'sync' it, but the only thing other components need is the chosen city, which I set on submit."

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Attack a build in vertical slices and milestones β€” always keep something that runs.
  • Design state before UI: local for transient UI, a shared store for genuinely shared values, and a separate cache for server data.
  • Async isn't done until loading, empty, and error states are handled and races can't corrupt the view.
  • Persistence via localStorage should degrade gracefully when storage is unavailable.
  • "Done" is the checklist; "good" is small components, resilience, accessibility, and a readable repo.

🎯 Quick Quiz

Question 1: The user's search-box text should normally live where?

Question 2: Why does the fetch effect use a cancelled cleanup flag?

Question 3: Which practice best matches the "what good looks like" bar for handling storage?

πŸ“š Further Reading

πŸš€ What's Next?

You've now shipped a real stateful frontend. Next module we turn to the server side of security: Authentication Models and Workflows β€” how apps prove who a user is and keep them signed in safely.

πŸŽ‰ Ship it!

Commit your final milestone, write that README, and give yourself credit β€” you built and reasoned about a complete stateful app in a weekend.