React Essential Guide With Examples
Most people learning React today skip straight to tutorials that show them how to build a counter app or a todo list. That stuff works fine for getting started, but it gives you a completely wrong impression of what the library actually does when something breaks. Here is the short version of what matters after you get past the basics.React Essential Guide With Examples
The core of React is not hooks, not components, not even JSX. It is a rendering model built around a virtual DOM and a commit phase that batches state updates. Understanding that model changes how you write everything else. I spent a week debugging a page where inputs were flickering on every keystroke in a large form. Turns out I was calling a setter function inside the render body of a parent component instead of inside an event handler. Every render triggered a state update, which triggered another render, and React was doing exactly what I told it to do, just not the way I wanted. The fix was moving the setState call into the onChange handler and memoizing the callback so the child wouldn't re-render on every parent change.
How the rendering model actually works
When you call a state updater like setCount, React schedules a re-render. It does not happen immediately. In React 18, multiple state updates inside the same synchronous block get batched together into a single render pass. That is a big deal because it means you can update five pieces of state in one event handler and the component only renders once, not five times. But there is a catch. Outside of React event handlers, in setTimeout or Promise callbacks, batching behaves differently depending on your React version. In React 17 and earlier, those updates were not batched by default. You had to use ReactDOM.unstable_batchedUpdates. In React 18, they are batched by default, which broke some code in my projects that relied on the old behavior. I had a notification system that expected a state update to trigger a render before the next network request fired. Once I upgraded to React 18, the requests ended up firing against stale state because the render was deferred. The workaround was wrapping the update in ReactDOM.flushSync to force an immediate synchronous render. That flushSync call is expensive. It defeats the whole batching optimization. Use it sparingly and only when you genuinely need the render to happen before the next line of code executes.
Components and props, the way they actually behave
A common mistake I see constantly is treating components like classes with properties. In React, props are immutable inputs passed down from parent to child. You cannot change them inside the child component. If a child needs to communicate a change back up, it calls a function prop that the parent provided. This unidirectional data flow is why debugging state issues is usually just a matter of tracing which parent set the value and why. Here is a realistic example. You have a data table component that receives an array of items and a callback when a row is clicked. You want to track which row is selected.
Get the Full Details
Example: controlled selection in a table
import { useState } from 'react';
function DataTable({ items, onSelect }) {
const [selectedId, setSelectedId] = useState(null);
return (
<table>
<tbody>
{items.map(item => (
<tr
key={item.id}
onClick={() => {
setSelectedId(item.id);
if (onSelect) onSelect(item);
}}
style={{ background: selectedId === item.id ? '#e0e0e0' : 'white' }}
>
<td>{item.name}</td>
<td>{item.value}</td>
</tr>
))}
</tbody>
</table>
);
}
export default function App() {
const [selected, setSelected] = useState(null);
const items = [{ id: 1, name: 'Apple', value: 100 }, { id: 2, name: 'Banana', value: 200 }];
return (
<div>
<DataTable items={items} onSelect={setSelected} />
<p>Selected: {selected ? selected.name : 'None'}</p>
</div>
);
}
This works fine for small lists. When the list grows past a few hundred rows, the component will re-render the entire table on every selection change because the rows are recreated on each render. The fix is wrapping the row logic in React.memo and making sure the key prop is stable. Even better is using a virtualized list library like react-window if you are dealing with thousands of rows. useEffect is the hook that causes the most problems. People use it for everything: data fetching, subscriptions, manual DOM manipulation, cleanup logic. The hook is meant for side effects that do not belong in the render phase. A side effect is anything that happens outside the component tree, like fetching data from an API or subscribing to an event listener. Here is the pattern I use for data fetching without running into stale closure problems or infinite request loops.
Example: safe data fetching with useEffect
import { useState, useEffect } from 'react';
function UserList({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch('/api/users/' + userId)
.then(res => res.json())
.then(data => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [userId]);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <p>{user.name}</p>;
}
The cancelled flag is the part most tutorials skip. Without it, if the component unmounts or userId changes while the fetch is still in flight, the setState calls after the resolve will run on an unmounted component or with stale data. React 18 added a warning for this exact scenario, which is helpful but not a substitute for proper cleanup. Another hook that causes trouble is useMemo. People wrap everything in it thinking it will make their app faster. It does not. useMemo only memoizes the result of a computation. If the computation is cheap, wrapping it in useMemo adds overhead without any benefit. I only use useMemo for expensive calculations like filtering and transforming large datasets, or for stabilizing objects that are passed as props to memoized children.
Example: useMemo for expensive computation
import { useMemo } from 'react';
function ExpensiveFilter({ items, searchQuery }) {
const filteredItems = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [items, searchQuery]);
return (
<ul>
{filteredItems.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
}
The dependency array here is critical. If you omit items or searchQuery, the memoized value will never recalculate when those values change. If you add extra dependencies that do not affect the result, you defeat the purpose of memoization. Keep the array tight. React context is not a state management solution. It is a way to pass data through the component tree without prop drilling. The problem with context is that when the value changes, every component that consumes it re-renders, regardless of whether that specific component actually uses the changed part of the value. This is a real performance trap in medium to large applications. I ran into this when building a dashboard with a theme context and a user context merged into a single provider. Changing the user profile would cause the theme-dependent components to re-render unnecessarily. The solution was splitting them into separate contexts. Each consumer only subscribed to the context it actually needed. This is one of those things that is not obvious until you are profiling a slow app and wonder why everything is re-rendering on a button click.

Example: split contexts to limit re-renders
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
const UserContext = createContext();
export function AppProviders({ children }) {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
<ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
export function useUser() {
return useContext(UserContext);
}
For more complex state that involves async operations, side effects, or needs time-travel debugging, a dedicated state management library like Zustand or Redux Toolkit is worth the setup cost. Zustand is simpler and has less boilerplate. Redux Toolkit is more structured and integrates well with DevTools. Neither is necessary for small apps, but trying to manage complex app state with just useState and context will become a maintenance problem quickly. Creating objects or functions inside the render body is a silent performance killer. Every render produces a new object reference, which means memoized children receiving that object as a prop will re-render even if the data inside has not changed. Here is what that looks like in practice and how to fix it.
Example: stabilize objects and functions
import { useState, useMemo, useCallback } from 'react';
function Parent() {
const [count, setCount] = useState(0);
const [name, setName] = useState('test');
const config = useMemo(() => ({
apiUrl: 'https://api.example.com',
timeout: 5000,
retries: 3,
}), []);
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);
return (
<Child config={config} onClick={handleClick} name={name} />
);
}
const Child = React.memo(function Child({ config, onClick, name }) {
return (
<div>
<button onClick={onClick}>Count</button>
<p>Hello, {name}</p>
</div>
);
});
The useMemo with an empty dependency array creates the config object once. useCallback with an empty dependency array creates the handler once. Without these, Child would re-render on every Parent render because config and handleClick would be new references each time. React.memo compares prop references, not values, so new references mean new renders. There is a limit to how far this optimization goes. Over-memoizing adds complexity and makes the code harder to read. I usually only memoize when I have a profiling tool like React DevTools profiler showing actual unnecessary re-renders. Guessing where the bottlenecks are usually leads to premature optimization that wastes more time than it saves.
Forms and validation without the headache
React forms are straightforward in theory. Controlled inputs tie the input value to state and the onChange handler updates that state. The problem is that real forms have validation, error messages, nested fields, and async submission logic. Writing all of that by hand gets repetitive fast. I stopped writing custom form logic for anything beyond simple single-field inputs. For anything real, I use a library like react-hook-form or Formik. react-hook-form is lighter and has less re-rendering overhead because it uses refs internally instead of state for most field management. Formik has a longer history and a larger ecosystem but is heavier.
Example: react-hook-form basic setup
import { useForm } from 'react-hook-form';
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = async (data) => {
const response = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Signup failed');
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Email is required', pattern: { value: /\S+@\S+\.\S+/, message: 'Invalid email' } })} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register('password', { required: 'Password is required', minLength: { value: 8, message: 'Minimum 8 characters' } })} type="password" placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Sign up</button>
</form>
);
}
The register function attaches validation rules directly to the input. The errors object contains any validation failures keyed by field name. This is cleaner than managing separate state for each field and each error message. If you are using TypeScript with React, the biggest friction point is typing components correctly. Generic component props, inferred event types, and proper hook typing are where most people get stuck. The keyof type utility is useful here. It lets updateField accept any property name from FormState while keeping type safety. The generic Event types from React, like ChangeEvent and FormEvent, prevent you from having to type event handlers manually.
React is not ideal for every project. If you are building a simple static page with no interactivity, vanilla HTML and CSS is faster to write and easier to maintain. If you are building a heavy data visualization dashboard with thousands of simultaneous updates, a library like D3 or Svelte might give you better performance out of the box. React has overhead from its reconciliation process that simple projects do not need. Server-side rendered applications also have trade-offs. Next.js is the standard for SSR with React, but it adds complexity around hydration, edge cases with third-party libraries, and deployment configuration. If your content is mostly static and SEO is not a concern, a client-side React app is simpler. If SEO and initial load performance are critical, Next.js is worth the investment.
Example: deciding between CSR and SSR
A blog with weekly posts and heavy user comments benefits from SSR for the post pages. The initial HTML is served quickly and SEO crawlers can read it. The comment section can still be client-side rendered since it changes frequently and does not need to be indexed. A real-time chat application does not need SSR at all because the entire UI is dynamic and there is no SEO concern. Matching the rendering strategy to the content type saves a lot of debugging later. React continues to evolve with concurrent features, server components, and improved hydration. The fundamentals discussed here remain relevant regardless of which version you are using. The rendering model, the rules of hooks, and the understanding of when to optimize are the things that actually matter in day-to-day development.
