React in 10 Minutes: Build a Real App (React 19)

4 views

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, then npm run dev. – State changes trigger re-renders. Never mutate state; always create a new value. – Give list items stable keys, such as IDs, not array indexes. – Clean up useEffect fetches with AbortController to 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

bash
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

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

jsx
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

jsx
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

jsx
<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

jsx
<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

jsx
{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

jsx
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

jsx
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()

jsx
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

jsx
<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

jsx
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

jsx
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

jsx
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

jsx
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

bash
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, useMemo and useCallback only 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:

jsx
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 lazy with Suspense keeps bundles small.

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

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.

Subscribe free →


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.