Seven core React principles explained: pure functions, hook constraints, state behavior, and performance patterns. Essential knowledge for React developers.

Seven core React principles explained: pure functions, hook constraints, state behavior, and performance patterns. Essential knowledge for React developers.
I think most small things and syntax are easily learnable after some time, no matter which language you're using. JavaScript is infamous for having some diabolical quirks when writing it and some unexpected behaviors in how it returns values.
Like everything in life, knowing how a tree functions won't help you in your everyday actions, but having many concepts inside your head helps tremendously when doing the hardest tasks every day: thinking.
Knowing how React functions fundamentally helped me immensely to write code using it, even though I technically don't need to know how React works underneath the hood. React also has some confusing stuff, but go through their learning documentation once and you'll be good after writing some code yourself.
The most important thing when learning any library, language, framework, or React is to understand its foundational assumptions. I would guess the same goes for life.
Here are the foundational assumptions for React:
Core principle: Functions should always return the same output for the same input.
If you start relying on some outside changeable value for the output, React will break. Pure functions are the backbone of predictable React components.
let multiplier = 2;
function CalculatePrice({ basePrice }) {
// This depends on external 'multiplier' - impure!
return <div>Price: ${basePrice * multiplier}</div>;
}
✅ Pure Function (Good)
function CalculatePrice({ basePrice, multiplier }) {
// All inputs are passed as props - pure!
return <div>Price: ${basePrice * multiplier}</div>;
}
Why it matters: React relies on pure functions to optimize rendering. When components are pure, React can safely skip re-renders when props haven't changed.
Core principle: Never call hooks conditionally or inside loops.
This means you can't use useState, useEffect, or any other hook inside an if statement or anything that might occur in one instance but not the other.
function UserProfile({ isLoggedIn }) {
if (isLoggedIn) {
const [user, setUser] = useState(null); // ❌ Don't do this!
}
// ...
}
✅ Top-Level Hook (Good)
function UserProfile({ isLoggedIn }) {
const [user, setUser] = useState(null); // ✅ Always at top level
if (!isLoggedIn) {
return <div>Please log in</div>;
}
// ...
}
Why it matters: React relies on the order of hook calls to preserve state between renders. Conditional hooks break this order, causing bugs.
// ❌ Bad - hooks in a loop
function ItemList({ items }) {
items.forEach(item => {
const [selected, setSelected] = useState(false); // ❌ Never works!
});
}
// ✅ Good - create separate components
function ItemList({ items }) {
return items.map(item => <Item key={item.id} item={item} />);
}
function Item({ item }) {
const [selected, setSelected] = useState(false); // ✅ Each component has its own hook
// ...
}
Core principle: Minimize unnecessary re-renders by keeping parent components stable.
If a parent component of 7 children components changes, all of them need to re-render. This is expensive and really inefficient. As hard as it is, try to make components as independent as possible regarding their state, and by that, their re-rendering conditions too.
❌ Inefficient Structure
function Dashboard() {
const [searchQuery, setSearchQuery] = useState('');
return (
<div>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<Sidebar /> {/* Re-renders on every search keystroke! */}
<MainContent /> {/* Re-renders on every search keystroke! */}
<Footer /> {/* Re-renders on every search keystroke! */}
</div>
);
}
✅ Optimized Structure
function Dashboard() {
return (
<div>
<SearchSection /> {/* State isolated here */}
<Sidebar />
<MainContent />
<Footer />
</div>
);
}
function SearchSection() {
const [searchQuery, setSearchQuery] = useState('');
return <SearchBar value={searchQuery} onChange={setSearchQuery} />;
}
Pro tip: Push state down to the lowest common ancestor. If only one component needs state, keep it there instead of lifting it to a parent.
Real-World Example
// Instead of managing all form state at the top level
function Form() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [address, setAddress] = useState('');
const [phone, setPhone] = useState('');
return (
<>
<EmailInput value={email} onChange={setEmail} />
<PasswordInput value={password} onChange={setPassword} />
<AddressInput value={address} onChange={setAddress} />
<PhoneInput value={phone} onChange={setPhone} />
</>
);
}
// Better: Let each input manage its own state
function Form() {
return (
<>
<EmailInput />
<PasswordInput />
<AddressInput />
<PhoneInput />
</>
);
}
Core principle: State updates are asynchronous and apply to the next render.
React works by using snapshots. When you change state, the new value won't be available until the next render cycle.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // Still 0! Not 1!
setCount(count + 1);
console.log(count); // Still 0! Not 2!
}
return <button onClick={handleClick}>Count: {count}</button>;
}
What happens: Both setCount calls use the same snapshot value (0), so clicking once only increments by 1, not 2.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(prevCount => prevCount + 1); // Uses latest value
setCount(prevCount => prevCount + 1); // Uses latest value
// Now clicking once will increment by 2!
}
return <button onClick={handleClick}>Count: {count}</button>;
}
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
const newCount = count + 5;
setCount(newCount);
console.log(count); // Still 0 (current render)
console.log(newCount); // 5 (calculated value)
}
return <button onClick={handleClick}>Count: {count}</button>;
}
function ContactForm() {
const [message, setMessage] = useState('');
const [status, setStatus] = useState('idle');
async function handleSubmit(e) {
e.preventDefault();
setStatus('sending');
console.log(status); // Still 'idle'! Not 'sending'!
// If you need the new status immediately, use a variable:
const newStatus = 'sending';
await sendMessage(message);
setStatus('sent');
}
return (
<form onSubmit={handleSubmit}>
<textarea value={message} onChange={e => setMessage(e.target.value)} />
<button disabled={status === 'sending'}>Send</button>
</form>
);
}
Core principle: Always create copies when updating arrays or objects.
React doesn't remember changes between renders. Every render creates a "new" component instance, even though it looks the same to us. This is why let num = 0 will always be 0 on new renders, even though we might have increased it to 10 during a render.
❌ Direct Mutation (Bad)
function TodoList() {
const [todos, setTodos] = useState([]);
function addTodo(text) {
todos.push({ id: Date.now(), text }); // ❌ Mutating directly!
setTodos(todos); // React won't detect the change!
}
return (/* ... */);
}
✅ Create New Copy (Good)
function TodoList() {
const [todos, setTodos] = useState([]);
function addTodo(text) {
const newTodo = { id: Date.now(), text };
setTodos([...todos, newTodo]); // ✅ New array created
}
return (/* ... */);
}
More Examples with Objects
function UserProfile() {
const [user, setUser] = useState({ name: 'John', age: 30 });
// ❌ Bad - direct mutation
function updateAge(newAge) {
user.age = newAge;
setUser(user); // React won't detect this!
}
// ✅ Good - spread operator
function updateAge(newAge) {
setUser({ ...user, age: newAge });
}
// ✅ Also good - Object.assign
function updateName(newName) {
setUser(Object.assign({}, user, { name: newName }));
}
return (/* ... */);
}
Nested Objects Example
function Settings() {
const [settings, setSettings] = useState({
theme: 'dark',
notifications: {
email: true,
push: false
}
});
// ❌ Bad - mutating nested object
function toggleEmail() {
settings.notifications.email = !settings.notifications.email;
setSettings(settings);
}
// ✅ Good - deep copy
function toggleEmail() {
setSettings({
...settings,
notifications: {
...settings.notifications,
email: !settings.notifications.email
}
});
}
return (/* ... */);
}
Array Operations Cheat Sheet
const [items, setItems] = useState([1, 2, 3]);
// Add item
setItems([...items, 4]); // ✅
items.push(4); setItems(items); // ❌
// Remove item
setItems(items.filter(item => item !== 2)); // ✅
items.splice(1, 1); setItems(items); // ❌
// Update item
setItems(items.map(item => item === 2 ? 20 : item)); // ✅
items[1] = 20; setItems(items); // ❌
// Sort items
setItems([...items].sort()); // ✅
items.sort(); setItems(items); // ❌
Master these hooks and you'll have most benefits of React at your fingertips.
useStateThe foundation of interactive components.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
Important notes:
useEffect without dependencies (infinite loop)// ❌ Infinite loop!
function BadComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // Causes re-render → triggers effect → causes re-render...
});
return <div>{count}</div>;
}
// ✅ Controlled with dependencies
function GoodComponent() {
const [count, setCount] = useState(0);
useEffect(() => {
// Only runs once on mount
setCount(1);
}, []); // Empty dependency array
return <div>{count}</div>;
}
useContextShare data across the component tree without prop drilling.
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
return <ThemedButton />; // No props passed!
}
function ThemedButton() {
const theme = useContext(ThemeContext); // Gets 'dark'
return <button className={theme}>Click me</button>;
}
Important notes:
// ❌ Bad - everything re-renders when anything changes
const AppContext = createContext();
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [language, setLanguage] = useState('en');
return (
<AppContext.Provider value={{ user, theme, language }}>
{/* All children re-render when theme changes, even if they only need user */}
</AppContext.Provider>
);
}
// ✅ Good - split contexts by concern
const UserContext = createContext();
const ThemeContext = createContext();
const LanguageContext = createContext();
function App() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [language, setLanguage] = useState('en');
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<LanguageContext.Provider value={language}>
{/* Components only re-render when their specific context changes */}
</LanguageContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
useEffectHandle side effects that happen outside of React's rendering.
"A side effect caused by rendering. To refer to the broader programming concept, we'll say 'side effect'."
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
// Fetch runs after component renders
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // Re-run when userId changes
return user ? <div>{user.name}</div> : <div>Loading...</div>;
}
Use for:
Common patterns:
// Cleanup function
useEffect(() => {
const timer = setTimeout(() => {
console.log('Delayed message');
}, 1000);
// Cleanup runs when component unmounts or before effect re-runs
return () => clearTimeout(timer);
}, []);
// Multiple dependencies
useEffect(() => {
console.log('Either name or age changed');
}, [name, age]);
// Run only once on mount
useEffect(() => {
console.log('Component mounted');
}, []);
// Run on every render (rarely needed!)
useEffect(() => {
console.log('Component rendered');
}); // No dependency array
Important notes:
useRefA pocket outside of React for values that persist but don't trigger re-renders.
function VideoPlayer() {
const videoRef = useRef(null);
function handlePlay() {
videoRef.current.play(); // Direct DOM manipulation
}
function handlePause() {
videoRef.current.pause();
}
return (
<>
<video ref={videoRef} src="movie.mp4" />
<button onClick={handlePlay}>Play</button>
<button onClick={handlePause}>Pause</button>
</>
);
}
Important notes:
// Storing previous value
function Counter() {
const [count, setCount] = useState(0);
const prevCountRef = useRef();
useEffect(() => {
prevCountRef.current = count;
});
const prevCount = prevCountRef.current;
return (
<div>
<p>Current: {count}</p>
<p>Previous: {prevCount}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// Avoiding stale closures in intervals
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
useEffect(() => {
countRef.current = count; // Always keep ref in sync
});
useEffect(() => {
const interval = setInterval(() => {
// This always has the latest count
console.log('Current count:', countRef.current);
}, 1000);
return () => clearInterval(interval);
}, []); // No dependencies needed!
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
useMemo and useCallback?Before, useMemo and useCallback were considered essential hooks for optimization. However, since React Compiler is almost out (which does their job automatically), they're not as critical to learn upfront.
That said, they're still useful to understand:
// useMemo - memoize expensive calculations
function ProductList({ products }) {
const sortedProducts = useMemo(() => {
return products.sort((a, b) => b.price - a.price);
}, [products]); // Only re-sort when products change
return (/* render sortedProducts */);
}
// useCallback - memoize function references
function ParentComponent() {
const [count, setCount] = useState(0);
// Without useCallback, handleClick is a new function on every render
const handleClick = useCallback(() => {
console.log('Clicked');
}, []); // Function reference stays the same
return <ChildComponent onClick={handleClick} />;
}
This is not exactly an assumption, but a best practice that's saved me countless hours.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<App />
</StrictMode>
);
What Strict Mode does:
It's easy to miss something important (not using a pure function or using a hook conditionally) when writing a lot of code, and sometimes those mistakes won't be visible right away. Strict Mode gives you a safe way to write this sometimes confusing framework (/library...).
In general, your components should be resilient to being remounted. Strict Mode runs everything twice in development, so you'll be able to check if your app is resilient to being remounted multiple times.
let globalCounter = 0;
// This component is impure
function BrokenCounter() {
globalCounter++; // Side effect during render!
return <div>{globalCounter}</div>;
}
// In Strict Mode, this will show unexpected behavior:
// First render: 1
// Second render: 2 (should also be 1!)
// This immediately tells you something is wrong
Example: Catching Missing Cleanup
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
// ❌ Forgot cleanup - Strict Mode will expose this!
// Component mounts → connects
// Strict Mode remounts → connects again (now 2 connections!)
}, [roomId]);
}
// ✅ Fixed with cleanup
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect(); // Proper cleanup
}, [roomId]);
}
These seven foundational assumptions form the mental model you need to write effective React code. Once you internalize them, React's "magic" becomes predictable and logical:
Master these concepts, and you'll spend less time debugging and more time building. The framework will start to feel intuitive rather than magical.