React Tutorial
A hands-on, practical tutorial series to learn React by building real components and apps.
What you'll learn
This series takes you from the basics of React up through intermediate concepts used in real-world apps. Each section includes short exercises and links to try the code in the browser.
- React fundamentals: components, props, and JSX
- State and lifecycle with hooks (useState, useEffect)
- Advanced hooks and custom hooks
- Data fetching strategies and caching
- Component patterns, composition, and state management
- Testing, accessibility, performance, and deployment
Full roadmap (beginner → finish)
- Setup & Tooling: Create a project (Next.js or Vite), set up ESLint, Prettier, and TypeScript (optional).
- JSX & Components: Function components, props, children, and composition.
- State & Hooks: Local state (useState), effects (useEffect), refs (useRef).
- Reusable logic: Build custom hooks and shared utilities.
- Data & side-effects: Fetching, caching, error handling, and optimistic updates.
- Global state: Context API, when to use a store (Redux / Zustand).
- Routing: Client navigation, dynamic routes, and data loading patterns (Next.js).
- Testing & accessibility: Unit & integration tests, semantic HTML, ARIA basics.
- Performance: Code-splitting, memoization, lazy loading, and profiling.
- Deployment: Build targets, environment variables, and production optimizations.
Exercises & examples
Try small exercises as you progress. They reinforce concepts and give code you can reuse.
- Counter with
useState
- Todo list persisted to localStorage
- Fetch posts from a public API and show loading/error states
- Refactor shared logic into a custom hook
Example: basic counter
import { useState } from 'react'
function Counter(){
const [count, setCount] = useState(0)
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
)
}
Example: simple todo (local state + localStorage)
import { useEffect, useState } from 'react'
function useLocalStorage(key, initial) {
const [state, setState] = useState(() => {
try {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : initial
} catch { return initial }
})
useEffect(() => { localStorage.setItem(key, JSON.stringify(state)) }, [key, state])
return [state, setState]
}
function TodoApp(){
const [todos, setTodos] = useLocalStorage('todos', [])
const [text, setText] = useState('')
const add = () => { if (!text) return; setTodos(t => [...t, { id: Date.now(), text, done: false }]); setText('') }
return (
<div>
<input value={text} onChange={e => setText(e.target.value)} />
<button onClick={add}>Add</button>
<ul>{todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>
</div>
)
}
Example: data fetch (useEffect)
import { useEffect, useState } from 'react'
function Posts(){
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let mounted = true
fetch('https://jsonplaceholder.typicode.com/posts')
.then(r => r.json())
.then(data => { if (mounted) setPosts(data.slice(0,10)) })
.finally(() => { if (mounted) setLoading(false) })
return () => { mounted = false }
}, [])
if (loading) return <p>Loading...</p>
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
Example: custom hook (useToggle)
import { useCallback, useState } from 'react'
function useToggle(initial = false){
const [on, setOn] = useState(initial)
const toggle = useCallback(() => setOn(o => !o), [])
return [on, toggle]
}
function Demo(){
const [on, toggle] = useToggle()
return <button onClick={toggle}>{on ? 'ON' : 'OFF'}</button>
}
Example: controlled form
import { useState } from 'react'
function ContactForm(){
const [form, setForm] = useState({ name: '', email: '' })
const set = (k, v) => setForm(f => ({ ...f, [k]: v }))
const submit = (e) => { e.preventDefault(); console.log(form) }
return (
<form onSubmit={submit}>
<input value={form.name} onChange={e => set('name', e.target.value)} placeholder="Name" />
<input value={form.email} onChange={e => set('email', e.target.value)} placeholder="Email" />
<button type="submit">Send</button>
</form>
)
}
Best practices (do these)
- Keep components small and focused on one responsibility.
- Prefer composition: use props and children to make components flexible.
- Use custom hooks to share logic and keep components declarative.
- Show clear loading and error states for async operations.
- Write tests for important components and logic paths.
- Use semantic HTML and handle keyboard interactions for accessibility.
- Measure before optimizing; use React Profiler to find real bottlenecks.
Good example: small, focused component
function Avatar({ src, alt }){
// Single responsibility: render an avatar image with fallback
return (
<img src={src} alt={alt} className="h-10 w-10 rounded-full object-cover" />
)
}
Good example: custom hook for data
import { useEffect, useState } from 'react'
function useFetch(url){
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(()=>{
let mounted = true
fetch(url)
.then(r => r.json())
.then(d => { if (mounted) setData(d) })
.catch(e => { if (mounted) setError(e) })
.finally(()=>{ if (mounted) setLoading(false) })
return ()=> { mounted = false }
}, [url])
return { data, loading, error }
}
Good example: accessible button
function IconButton({ onClick, label, icon }){
return (
<button onClick={onClick} aria-label={label} className="p-2 rounded hover:bg-gray-100">
{icon}
</button>
)
}
What NOT to do (anti-patterns)
- Avoid mutating state directly — always return new objects/arrays.
- Don't place heavy logic in render; move to hooks or helper functions.
- Don't fetch data in many components redundantly — centralize or cache it.
- Don't use global state for everything; lift state up only when necessary.
- Don't ignore accessibility — it’s often overlooked but essential.
Bad example: mutating state directly
// DON'T do this
const [list, setList] = useState([1,2,3])
function add(){
list.push(4) // mutation
setList(list)
}
// DO instead
function addSafe(){
setList(l => [...l, 4])
}
Bad example: heavy logic in render
// DON'T: complex filtering and sorting inside JSX
function BigComponent({ items }){
return <div>{items.filter(i => expensiveCheck(i)).map(i => <Item key={i.id} i={i} />)}</div>
}
// DO: compute outside render or memoize
function BigComponent({ items }){
const processed = useMemo(()=> items.filter(i => expensiveCheck(i)).sort(bySomething), [items])
return <div>{processed.map(i => <Item key={i.id} i={i} />)}</div>
}
Bad example: huge component that does everything
// DON'T: one massive component
function App(){
// handles UI, data, routing, forms, and more — hard to maintain
return <div>...lots of code...</div>
}
// DO: split into focused components and hooks
function App(){
return (
<Layout>
<Posts />
<Sidebar />
</Layout>
)
}
Tooling & next steps
When you're comfortable with the core concepts, level up with tooling and libraries:
- Use ESLint + Prettier for consistent code style.
- Try TypeScript to catch errors at compile time.
- Learn a data fetching/caching library (SWR, React Query).
- Explore state libraries when needed (Zustand, Redux Toolkit).