Back to blog
Code
Nov 24, 2025

7 Foundational Assumptions About React

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

7 Foundational Assumptions About React

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:

1. You Must Use Pure Functions

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.

❌ Impure Function (Bad)

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.

2. Hooks Must Be Used at the Top Level of Components

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.

❌ Conditional Hook (Bad)

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.

Another Example: Loop Hooks

// ❌ 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
 // ...
}

3. Make Top-Level Components Not Change Often

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 />
   </>
 );
}

4. Changing the State Changes It for the Next Render

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.

The Snapshot Problem

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.

✅ Solution 1: Use Functional Updates

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>;
}

✅ Solution 2: Use a Temporary Variable

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>;
}

Real-World Example: Form Submission

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>
 );
}

5. You Can't Directly Change Any Hook Value

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); // ❌

6. Fundamentals of React Are in Only a Few Hooks

Master these hooks and you'll have most benefits of React at your fingertips.

useState

The foundation of interactive components.

function Counter() {
 const [count, setCount] = useState(0);
 
 return (
   <button onClick={() => setCount(count + 1)}>
     Clicked {count} times
   </button>
 );
}

Important notes:

  • Always re-renders component and all components inside of it
  • Be careful not to use it inside 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>;
}

useContext

Share 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:

  • Don't use if you can avoid it (if you're not passing props through 10+ components...)
  • Every component using the context will re-render when context value changes
  • Consider splitting contexts if some data changes more frequently

// ❌ 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>
 );
}

useEffect

Handle 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:

  • API calls
  • DOM updates (setting document.title, etc.)
  • Setting up subscriptions
  • Timers (setTimeout, setInterval)

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:

  • Use for something outside of actual React code (DOM updates, API calls)
  • Always specify dependencies to avoid stale closures
  • Clean up subscriptions and timers to prevent memory leaks

useRef

A 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:

  • Similar to state, but doesn't re-render the component when changed
  • Should be used only for stuff that doesn't directly affect rendering
  • Perfect for: DOM references, storing previous values, timers, any mutable value

// 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>;
}

What About 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} />;
}

7. Use Strict Mode When Developing

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:

  • Runs components twice to catch impure functions
  • Warns about deprecated APIs
  • Detects unexpected side effects
  • Helps identify unsafe lifecycles

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.

Example: Catching Impure Components

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]);
}

Conclusion

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:

  1. Pure functions keep your components predictable
  2. Top-level hooks maintain stable state
  3. Minimal re-renders keep your app fast
  4. State snapshots explain async behavior
  5. Immutable updates ensure React detects changes
  6. Core hooks give you all the tools you need
  7. Strict Mode catches mistakes early

Master these concepts, and you'll spend less time debugging and more time building. The framework will start to feel intuitive rather than magical.