Most React tutorials teach patterns that are years out of date: class components, useEffect for everything, and hand-rolled form state. This crash course builds one real project with modern React only. It’s a Weather Explorer that searches cities, saves favourites and shows live forecasts from the free Open-Meteo API, built with React 19.3, Vite 8 and React Router 8. Along the way it covers function components, hooks, Context with use(), React 19 form actions with useActionState and useFormStatus, and code splitting with lazy and Suspense. This written version follows the video’s 16 chapters and adds the reasoning behind each one.
⚡ TL;DR – Scaffold with Vite:
npm create vite@latest, thennpm run dev. – State changes trigger re-renders. Never mutate state; always create a new value. – Give list items stablekeys, such as IDs, not array indexes. – Clean upuseEffectfetches withAbortControllerto avoid race conditions. – React 19 form actions (useActionState) replace much of the old manual form boilerplate.
Before you start
- Node.js 20+ and npm.
- Basic JavaScript: arrow functions, destructuring,
fetch, promises. - No API key: Open-Meteo is free for non-commercial use.
Step 1: Vite setup
npm create vite@latest weather-explorer -- --template react
cd weather-explorer && npm install
npm install react-router
npm run dev
Vite serves your source as native ES modules with instant hot reload, and builds an optimised bundle for production.
Step 2: JSX
const temp = 31;
const el = <p className={temp > 30 ? 'hot' : 'mild'}>It is {temp}°C</p>;
JSX is JavaScript, not HTML. Use className and htmlFor, wrap expressions in {}, and return one root element (or a <>…</> fragment).
Step 3: Components and props
function DayCard({ date, max, min }) {
return (
<li className="day">
<strong>{new Date(date).toLocaleDateString(undefined, { weekday: 'short' })}</strong>
<span>{max}° / {min}°</span>
</li>
);
}
Components are functions that receive props and return UI. Props are read-only: a component should never change its own props.
Step 4: State
const [query, setQuery] = useState('');
const [favorites, setFavorites] = useState([]);
const add = city => setFavorites(prev => prev.includes(city) ? prev : [...prev, city]);
Calling a setter schedules a re-render. Use the updater form (prev => ...) when the next value depends on the previous one. Never push into state arrays: React compares references, so mutating in place won’t trigger an update.
Step 5: Events
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Search a city" />
<button onClick={() => add(query)} disabled={!query.trim()}>Save</button>
Pass a function, don’t call one: onClick={add} or onClick={() => add(x)}, never onClick={add(x)}. The last one runs on every render.
Step 6: Lists and keys
<ul>{days.map(d => <DayCard key={d.date} {...d} />)}</ul>
Keys let React match items between renders. Index keys break when items are reordered or removed, because state ends up attached to the wrong row.
Step 7: Conditional rendering
{error && <p role="alert">{error.message}</p>}
{loading ? <Spinner /> : <Forecast days={days} />}
{favorites.length === 0 && <p>No favourites yet.</p>}
Watch out for {count && ...}: when count is 0, React renders “0”. Compare explicitly instead.
Step 8: useEffect and data fetching
useEffect(() => {
if (!city) return;
const ctrl = new AbortController();
setLoading(true);
fetch(forecastUrl(city), { signal: ctrl.signal })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
.then(setData)
.catch(e => { if (e.name !== 'AbortError') setError(e); })
.finally(() => setLoading(false));
return () => ctrl.abort();
}, [city]);
The cleanup function aborts the previous request when city changes, so a slow old response can’t overwrite a newer one. In development, StrictMode deliberately runs effects twice to surface missing cleanups. That’s expected, not a bug.
Step 9: A custom hook
export function useFetch(url) {
const [state, setState] = useState({ data: null, error: null, loading: !!url });
useEffect(() => {
if (!url) return;
const ctrl = new AbortController();
setState(s => ({ ...s, loading: true }));
fetch(url, { signal: ctrl.signal })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(data => setState({ data, error: null, loading: false }))
.catch(error => error.name !== 'AbortError' && setState({ data: null, error, loading: false }));
return () => ctrl.abort();
}, [url]);
return state;
}
Custom hooks are just functions whose names start with use and that call other hooks. They let you reuse stateful logic, not UI.
Step 10: Context and use()
const FavoritesContext = createContext(null);
export function FavoritesProvider({ children }) {
const [favorites, setFavorites] = useState(() => JSON.parse(localStorage.getItem('fav') ?? '[]'));
useEffect(() => localStorage.setItem('fav', JSON.stringify(favorites)), [favorites]);
return <FavoritesContext value={{ favorites, setFavorites }}>{children}</FavoritesContext>;
}
function FavoriteButton({ city }) {
const { favorites, setFavorites } = use(FavoritesContext);
// ...
}
In React 19 you can render <Context> directly as the provider and read it with use(), which, unlike other hooks, can even be called inside conditions. Context avoids passing props through many layers, but every consumer re-renders when the value changes, so keep contexts focused.
Step 11: React Router
<BrowserRouter>
<nav><NavLink to="/">Search</NavLink> <NavLink to="/favorites">Favourites</NavLink></nav>
<Routes>
<Route path="/" element={<Search />} />
<Route path="/city/:name" element={<CityPage />} />
<Route path="/favorites" element={<Favorites />} />
</Routes>
</BrowserRouter>
function CityPage() {
const { name } = useParams();
const { data, loading, error } = useFetch(name ? geocodeUrl(name) : null);
// ...
}
NavLink adds an active class automatically. URL params make each city page shareable and bookmarkable.
Step 12: Forms, actions and useActionState
async function searchAction(prev, formData) {
const name = formData.get('city')?.trim();
if (!name) return { error: 'Type a city name' };
const r = await fetch(geocodeUrl(name));
const data = await r.json();
return data.results?.length ? { results: data.results } : { error: `No city called "${name}"` };
}
function Search() {
const [state, formAction] = useActionState(searchAction, {});
return (
<form action={formAction}>
<input name="city" />
<SubmitButton />
{state.error && <p role="alert">{state.error}</p>}
<ul>{state.results?.map(c => <li key={c.id}><Link to={`/city/${c.name}`}>{c.name}</Link></li>)}</ul>
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Searching…' : 'Search'}</button>;
}
The action receives FormData, and React tracks the pending state for you. useFormStatus must be called in a component inside the <form>, not in the component that renders it.
Step 13: useMemo, useCallback and memo
const stats = useMemo(() => {
const highs = days.map(d => d.max);
return { avg: highs.reduce((a, b) => a + b, 0) / highs.length, hottest: Math.max(...highs) };
}, [days]);
const remove = useCallback(city => setFavorites(f => f.filter(c => c !== city)), []);
const FavoriteRow = memo(function FavoriteRow({ city, onRemove }) { /* ... */ });
These are optimisations, not defaults. memo only helps if props are stable, which is why useCallback keeps remove the same function between renders. Measure with React DevTools before you sprinkle them around.
Step 14: useReducer
function reducer(state, action) {
switch (action.type) {
case 'add': return state.includes(action.city) ? state : [...state, action.city];
case 'remove': return state.filter(c => c !== action.city);
case 'clear': return [];
default: throw new Error(`Unknown action ${action.type}`);
}
}
const [favorites, dispatch] = useReducer(reducer, []);
When state transitions multiply, a reducer puts them all in one pure, testable function.
Step 15: Suspense and lazy
const Favorites = lazy(() => import('./Favorites.jsx'));
<Suspense fallback={<p>Loading…</p>}>
<Routes>{/* ... */}</Routes>
</Suspense>
lazy splits a route into its own chunk that’s downloaded only when needed, which shrinks the initial bundle.
Step 16: Production build
npm run build && npm run preview
Deploy the dist/ folder to any static host. Configure the host to serve index.html for unknown paths, or deep links like /city/London will return 404.
Troubleshooting
- “Too many re-renders”: you called a setter during render, e.g.
onClick={setX(1)}. - Stale data flashes: an effect fetch is missing its abort cleanup.
- “Each child should have a unique key”: add stable keys.
- The effect runs in a loop: an object or function dependency is recreated every render. Memoize it or move it inside the effect.
- 404 on refresh in production: add an SPA fallback on your host.
Best practices
- Keep state minimal; derive everything else during render.
- One custom hook per data source.
- Focused contexts. Use a reducer for complex transitions.
- Optimise with
memo,useMemoanduseCallbackonly after measuring.
Edge cases: optimistic updates and error boundaries
Optimistic UI with useOptimistic. Show a favourite as saved immediately, and roll it back automatically if the save fails:
const [optimisticFavs, addOptimistic] = useOptimistic(favorites, (list, city) => [...list, city]);
async function saveAction(formData) {
const city = formData.get('city');
addOptimistic(city);
await saveFavorite(city); // if this throws, React reverts the optimistic state
setFavorites(f => [...f, city]);
}
Error boundaries. A render error in one widget shouldn’t blank the whole page. Wrap risky sections in an error boundary (a small class component or the react-error-boundary package) with a fallback like “Could not show the forecast. Retry”.
FAQ
Do I need Next.js?
Not for a client-side app like this. Choose a framework when you need server rendering, SEO or server data loading.
Is useEffect bad?
No, but it’s for synchronising with external systems. Don’t use it to derive state.
Official documentation
Key takeaways
- Components plus immutable state form the core of React.
- Every fetch effect needs a cleanup.
- Use custom hooks and context for reuse and sharing.
- React 19 actions simplify forms, and
lazywith Suspense keeps bundles small.
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- REST API Design Best Practices: The Patterns That Make APIs a Joy to Use
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
PDF for this post is being prepared. Subscribe now and we’ll unlock downloads sitewide as packs go live.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.