Skip to content

React Senior Interview Preparation

This document consolidates the React interview discussion into an Astro-friendly Markdown file.

  1. React Rendering Lifecycle
  2. React.memo, useMemo, and useCallback
  3. React State Management
  4. Preventing Unnecessary Re-renders
  5. Diagnosing Stale Data After API Updates

React rendering can be understood as:

State / Props / Context change
Component render
New React element tree
Reconciliation
Commit DOM updates
Browser paints

When a React component renders, React executes the component function and produces a new React element tree.

function User({ name }) {
return <h1>Hello {name}</h1>;
}

Conceptually:

User("John")
<h1>Hello John</h1>

Rendering does not necessarily mean that React updates the real DOM.

React can render a new element tree, compare it with the previous tree, and determine that no DOM change is required.


A component may re-render when:

  • Its state changes
  • Its props change
  • Its parent renders
  • A consumed Context value changes
  • An external store subscription changes

Example:

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

When setCount() runs:

setCount()
React schedules an update
Counter() executes again
New React element tree
Reconciliation
Required DOM changes

A re-render does not mean the entire DOM is recreated.


The Virtual DOM is commonly described as React’s in-memory representation of the UI.

For example:

<div>
<h1>Hello</h1>
<p>Welcome</p>
</div>

React creates an internal representation of this UI.

When state changes:

Previous React tree
New React tree
Compare during reconciliation
Determine required changes
Commit DOM updates

Avoid saying:

“The Virtual DOM makes React automatically faster than every other framework.”

A better explanation is:

React’s declarative rendering model and reconciliation process allow React to determine the necessary UI updates rather than requiring developers to manually manipulate the DOM.


Reconciliation is React’s process of comparing the previous rendered tree with the new rendered tree to determine what needs to change.

For example:

<h1>Hello John</h1>
<h1>Hello David</h1>

React can identify that:

  • The <h1> still exists.
  • Only its content changed.

The DOM can therefore be updated accordingly.

Keys help React identify list items.

users.map(user => (
<User
key={user.id}
user={user}
/>
))

Keys help React reason about items that are:

  • Added
  • Removed
  • Updated
  • Reordered

Stable unique IDs are preferred over array indexes when list items can change order or be inserted/removed.


flowchart TD
    A["State / Props / Context change"] --> B["Component render"]
    B --> C["New React element tree"]
    C --> D["Reconciliation"]
    D --> E["Determine necessary changes"]
    E --> F["Commit DOM updates"]
    F --> G["Browser paints"]

React.memo can prevent a child component from rendering again when its props are unchanged.

const User = React.memo(function User({ name }) {
console.log("User rendered");
return <h2>{name}</h2>;
});

Suppose:

function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>
{count}
</button>
<User name="John" />
</>
);
}

When count changes, App renders again.

Without memoization, the child may render again.

With React.memo, React can skip the child when its props remain equal.

App re-renders
User props unchanged
React.memo
User render can be skipped

React.memo performs a shallow comparison of props by default.

This can cause a problem:

<User user={{ name: "John" }} />

A new object is created during every parent render.

Therefore:

previous user object !== new user object

The memoized component may render again.


useMemo caches the result of a calculation.

const filteredUsers = useMemo(() => {
return users.filter(user =>
user.name.includes(search)
);
}, [users, search]);

Conceptually:

Component render
Have dependencies changed?
/ \
No Yes
↓ ↓
Use cached Recalculate
value
  • Expensive filtering
  • Expensive sorting
  • Complex derived data
  • Expensive calculations
  • Maintaining stable derived object references when useful

Do not use useMemo for every calculation. Memoization has its own overhead and should solve an actual performance problem.


useCallback caches a function reference.

Consider:

const User = React.memo(({ onSelect }) => {
return (
<button onClick={onSelect}>
Select
</button>
);
});

Without useCallback:

function App() {
const handleSelect = () => {
console.log("Selected");
};
return <User onSelect={handleSelect} />;
}

A new function is created each time App renders.

With useCallback:

const handleSelect = useCallback(() => {
console.log("Selected");
}, []);

The function reference remains stable until its dependencies change.

Parent re-renders
useCallback
Same function reference
React.memo child sees unchanged prop
Child can skip render

Feature Purpose
React.memo Memoize a component’s rendering based on props
useMemo Memoize a calculated value
useCallback Memoize a function reference
React.memo → Component
useMemo → Value
useCallback → Function

Example:

const UserList = React.memo(({ users, onSelect }) => {
return users.map(user => (
<User
key={user.id}
user={user}
onSelect={onSelect}
/>
));
});
function App({ users }) {
const activeUsers = useMemo(() => {
return users.filter(user => user.active);
}, [users]);
const handleSelect = useCallback((id) => {
console.log(id);
}, []);
return (
<UserList
users={activeUsers}
onSelect={handleSelect}
/>
);
}

The optimization chain is:

flowchart TD
    A["App re-renders"] --> B["useMemo"]
    B --> C["Reuse activeUsers if dependencies unchanged"]
    A --> D["useCallback"]
    D --> E["Reuse handleSelect reference"]
    C --> F["UserList receives stable users reference"]
    E --> F
    F --> G["React.memo"]
    G --> H["Skip UserList render when props are unchanged"]

React rendering starts when state, props, context, or another subscribed source causes an update. React generates a new element tree, reconciles it with the previous tree, and commits the necessary DOM changes. React.memo can skip a child render when its props are unchanged, useMemo caches expensive computed values, and useCallback keeps function references stable. These should be used selectively after identifying actual performance bottlenecks.


The first step is to classify state.

flowchart TD
    A["Application State"] --> B["Client State"]
    A --> C["Server State"]

    B --> D["Local State"]
    B --> E["Shared State"]

    D --> F["useState / useReducer"]
    E --> G["Context API"]
    E --> H["Redux Toolkit"]
    E --> I["Zustand"]

    C --> J["TanStack Query"]

Use useState or useReducer when state belongs to a component or a small subtree.

const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState("");
  • Modal open/close
  • Form input
  • Selected tab
  • Dropdown state
  • Temporary UI state
  • Component-specific pagination

Use local state when only one component or a small part of the UI needs the data.

Avoid putting every UI value into global state.


Context allows data to be shared without passing props through every intermediate component.

const ThemeContext = createContext();
<ThemeContext.Provider value={theme}>
<App />
</ThemeContext.Provider>

Consume it:

const theme = useContext(ThemeContext);
  • Theme
  • Locale/language
  • Current user information
  • Application configuration
  • Feature flags

Context is not automatically a replacement for a complete state-management library.

Frequently changing Context values can cause many consumers to render.

Use Context for relatively simple shared state that does not require complex state transitions or advanced state-management features.


Redux provides centralized, predictable client-side state management.

The traditional flow is:

flowchart LR
    A["Component"] --> B["dispatch(action)"]
    B --> C["Reducer"]
    C --> D["Redux Store"]
    D --> E["Subscribed Components"]
    E --> F["Render"]

Example:

dispatch({
type: "cart/add",
payload: product
});

Traditional reducers can look like:

function cartReducer(state, action) {
switch (action.type) {
case "cart/add":
return {
...state,
items: [
...state.items,
action.payload
]
};
default:
return state;
}
}
  • Predictable state transitions
  • Centralized state
  • Excellent debugging
  • Middleware
  • Large ecosystem

Traditional Redux can involve significant boilerplate.

That is one reason Redux Toolkit is preferred for modern Redux applications.


Redux Toolkit (RTK) is the recommended modern approach to Redux.

Example:

const cartSlice = createSlice({
name: "cart",
initialState: {
items: []
},
reducers: {
addItem(state, action) {
state.items.push(action.payload);
},
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
}
}
});

RTK uses Immer internally to simplify immutable update logic.

  • configureStore
  • createSlice
  • createAsyncThunk
  • Middleware integration
  • Redux DevTools integration
  • RTK Query

RTK is a strong choice for large applications with:

  • Complex shared client state
  • Multiple modules
  • Complex state transitions
  • Strong debugging requirements
  • Multiple developers
  • Long-lived enterprise codebases

Zustand is a lightweight client-side state-management library.

Example:

const useCartStore = create((set) => ({
items: [],
addItem: (item) =>
set(state => ({
items: [
...state.items,
item
]
}))
}));

Component:

const items = useCartStore(
state => state.items
);
const addItem = useCartStore(
state => state.addItem
);
  • Very little boilerplate
  • Simple API
  • Easy to learn
  • Selective subscriptions
  • Lightweight
  • Shopping cart
  • UI preferences
  • Wizard state
  • Dashboard filters
  • Editor state
  • Small/medium applications

Use Zustand when you need shared client-side state but don’t need the structure and ecosystem of Redux Toolkit.


TanStack Query is primarily a server-state management library.

Server state is data that originates from a backend:

Customers
Orders
Products
Transactions
User profile

Example:

const {
data,
isLoading,
error
} = useQuery({
queryKey: ["customers"],
queryFn: fetchCustomers
});

Mutation example:

const mutation = useMutation({
mutationFn: createCustomer,
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["customers"]
});
}
});
  • Fetching
  • Caching
  • Loading state
  • Error state
  • Refetching
  • Stale data
  • Background updates
  • Request deduplication
  • Pagination
  • Infinite queries
  • Mutations
  • Cache invalidation

Without a dedicated server-state library, developers may end up manually managing:

loading
error
cache
stale data
refetch
retry
invalidation
pagination

TanStack Query solves these problems directly.


Solution Best For Example
useState Local UI state Modal, form, tab
useReducer Complex local state Multi-step form
Context API Simple shared state Theme, locale
Redux Complex global client state Large enterprise app
Redux Toolkit Modern Redux applications Enterprise client state
Zustand Lightweight global client state Cart, preferences
TanStack Query Server/API state Customers, orders

The most important distinction is:

React Application
┌─────────────┴─────────────┐
│ │
Client State Server State
│ │
┌──────┼──────┐ │
│ │ │ │
Local Context Redux/Zustand TanStack Query
State API

State primarily owned by the application/UI:

isModalOpen
selectedTab
theme
sidebarOpen
shopping cart
form state

Data originating from the backend:

customers
orders
products
transactions
user profile

For server state, TanStack Query is often a better fit than manually managing API data in Redux.


useState
+
Context where necessary

Avoid introducing Redux simply because it is popular.

A possible architecture is:

useState
+
Context
+
Zustand
+
TanStack Query

A common architecture could be:

Local UI State
useState / useReducer
Shared Client State
Redux Toolkit
Server State
TanStack Query / RTK Query

The important principle is:

You do not necessarily need one state-management library for every type of state.


19. Interview-Ready State Management Answer

Section titled “19. Interview-Ready State Management Answer”

I first classify state into local UI state, shared client state, and server state. For component-specific state, I use useState or useReducer. For simple cross-component state such as theme, locale, or user context, I use Context API. For complex global client state in an enterprise application, I prefer Redux Toolkit because it provides predictable state transitions, strong DevTools support, and less boilerplate than traditional Redux. For lightweight shared state, Zustand is a good alternative. For server state such as API data, caching, loading, retries, refetching, and invalidation, I prefer TanStack Query rather than putting all API state into Redux.

Local state → useState
Simple shared → Context
Complex global → Redux Toolkit
Lightweight → Zustand
Backend data → TanStack Query

20. Preventing Unnecessary Component Re-renders

Section titled “20. Preventing Unnecessary Component Re-renders”

The first step is to determine why the component is rendering.

Then apply targeted optimizations.


const UserCard = React.memo(({ user }) => {
console.log("UserCard rendered");
return (
<div>
{user.name}
</div>
);
});

If the parent re-renders but the relevant props remain equal, React can skip the child render.


This can cause unnecessary renders:

const handleClick = () => {
console.log("clicked");
};

A new function reference is created on every parent render.

Use:

const handleClick = useCallback(() => {
console.log("clicked");
}, []);

This is especially useful when the callback is passed to a memoized child.

flowchart TD
    A["Parent re-renders"] --> B["useCallback"]
    B --> C["Stable function reference"]
    C --> D["React.memo child"]
    D --> E["Child render can be skipped"]

const filteredUsers = useMemo(() => {
return users
.filter(user => user.active)
.sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [users]);

This avoids repeating an expensive calculation when its dependencies have not changed.


Avoid putting every UI state value into global state.

Instead of:

Global State
├── modalOpen
├── selectedTab
├── inputValue
├── theme
└── customers

keep component-specific state locally:

const [isOpen, setIsOpen] = useState(false);

This reduces the scope of state updates.


24. Avoid Unnecessary Object and Array Creation

Section titled “24. Avoid Unnecessary Object and Array Creation”

This can defeat memoization:

<UserCard user={{ name: "John" }} />

A new object is created during each parent render.

Stable references can be created when appropriate:

const user = useMemo(() => ({
name: "John"
}), []);

However, don’t add useMemo blindly. The best solution may simply be to avoid creating the object unnecessarily.


A large Context can cause broad re-rendering.

Instead of:

AppContext
├── user
├── theme
├── notifications
├── cart
└── application settings

consider splitting responsibilities:

UserContext
ThemeContext
CartContext
NotificationContext

This can reduce the number of consumers affected by an update.


Redux:

const user = useSelector(
state => state.user
);

Prefer selecting only what the component needs.

Zustand:

const username = useUserStore(
state => state.username
);

Selective subscriptions can reduce unnecessary rendering.


For thousands of records, rendering every item at once is expensive.

Instead of rendering all items:

users.map(user => (
<UserCard user={user} />
))

use list virtualization when appropriate.

Conceptually:

10,000 records
Virtualized list
Only visible rows rendered

This is useful for:

  • Tables
  • Logs
  • Search results
  • Large dashboards
  • Large lists

Use code splitting for large parts of an application:

const Reports = lazy(
() => import("./Reports")
);

Then:

<Suspense fallback={<Loading />}>
<Reports />
</Suspense>

Lazy loading primarily reduces initial JavaScript and load time rather than directly preventing ordinary re-renders.


29. React Performance Optimization Strategy

Section titled “29. React Performance Optimization Strategy”
flowchart TD
    A["Performance issue"] --> B["Use React DevTools Profiler"]
    B --> C["Identify rendering bottleneck"]

    C --> D["Unnecessary child renders"]
    C --> E["Expensive calculation"]
    C --> F["Large list"]
    C --> G["Large initial bundle"]
    C --> H["Too much shared state"]

    D --> I["React.memo"]
    D --> J["useCallback where useful"]
    E --> K["useMemo where useful"]
    F --> L["Virtualization"]
    G --> M["Lazy loading / code splitting"]
    H --> N["Local state / selectors / split Context"]

I first identify the reason for the re-render using React DevTools Profiler rather than applying memoization blindly. I use React.memo for components whose props don’t change frequently, useCallback to maintain stable function references when passing callbacks to memoized children, and useMemo for expensive calculations or stable derived values. I keep state as local as possible, avoid unnecessary object and array creation, split large Context providers, and use selectors for Redux or Zustand. For large lists, I use virtualization, and for large applications I use lazy loading and code splitting.

React.memo → Memoize component
useCallback → Memoize function
useMemo → Memoize value
Local state → Reduce render scope
Context splitting → Reduce consumers
Selectors → Subscribe to needed state
Virtualization → Reduce large-list rendering
Profiler → Identify bottlenecks

useMemo and useCallback are optimization tools, not guarantees that React will never render a component.

Use them when they address a real performance problem.


31. Production Issue: Stale Data After API Updates

Section titled “31. Production Issue: Stale Data After API Updates”

Suppose the user updates a customer:

User updates customer
PUT /customers/123
200 OK
GET /customers/123
Old customer data

The goal is to determine which layer is serving stale data.


Debug from the UI backward toward the database:

flowchart TD
    A["User sees stale data"] --> B["Inspect Browser Network request"]

    B --> C{"Does GET response contain NEW data?"}

    C -->|Yes| D["React state / client cache issue"]
    C -->|No| E["Inspect Browser Cache"]

    E --> F{"Is browser cache serving OLD response?"}

    F -->|Yes| G["Browser Cache"]
    F -->|No| H["Inspect CDN"]

    H --> I{"Is CDN serving OLD response?"}

    I -->|Yes| J["CDN Cache"]
    I -->|No| K["Inspect Application/API Cache"]

    K --> L{"Is API cache serving OLD value?"}

    L -->|Yes| M["API / Redis / Application Cache"]
    L -->|No| N["Compare Database Primary and Replica"]

    N --> O{"Primary NEW, Replica OLD?"}

    O -->|Yes| P["Replication Lag / Read-after-write consistency"]
    O -->|No| Q["Inspect API business logic / DB transaction"]

Open Browser DevTools → Network.

If the GET response contains the new data, but the UI shows old data, the problem is probably in client-side state or cache.

Possible causes:

setCustomer(updatedCustomer);

not being called correctly, stale derived state, or a client cache not being updated.

After mutation:

await updateCustomer(data);
queryClient.invalidateQueries({
queryKey: ["customer", customerId]
});

Or update the cache directly:

queryClient.setQueryData(
["customer", customerId],
updatedCustomer
);
Network GET → NEW
React UI → OLD
React state / client cache issue

Inspect:

  • Cache-Control
  • ETag
  • Last-Modified
  • Age
  • Expires
  • Request/response status
  • Memory cache / disk cache indicators

Try disabling cache in DevTools and reload.

If:

Normal request → OLD
Disable browser cache → NEW

browser caching is a strong suspect.

For data that should not be stored at all, an API may use:

Cache-Control: no-store

For data that can be stored but should be revalidated, an appropriate strategy may use:

Cache-Control: no-cache

The correct directive depends on the freshness and caching requirements.


A common architecture is:

Browser
CDN
API Gateway
Backend

Inspect CDN-related response headers. Depending on the provider, examples may include:

Age: 120
X-Cache: HIT
CF-Cache-Status: HIT

The exact headers vary by CDN.

If possible, compare:

Client → CDN → Origin

with a controlled origin request.

If:

CDN response → OLD
Origin → NEW

the CDN cache is likely stale.

  • Correct Cache-Control
  • Reduce TTL
  • Purge/invalidate CDN cache after updates
  • Avoid caching mutation-sensitive endpoints
  • Correct cache keys
  • Version resources where appropriate

Backend architecture may contain:

Controller
Service
Redis / Caffeine / Hazelcast
Database

Example:

@Cacheable("customers")
public Customer getCustomer(Long id) {
return repository.findById(id);
}

If the update occurs but the cache is not invalidated:

GET customer
Application cache
OLD customer

The update succeeds:

updateCustomer(customer);

but the cache remains unchanged.

Possible solution:

@CacheEvict(
value = "customers",
key = "#customer.id"
)

or update the cache after a successful database write.

Also inspect:

  • Cache key
  • TTL
  • Cache hit/miss
  • Cache value
  • Invalidation events

Distributed database architecture can create stale reads:

flowchart TD
    A["Application"] --> B["Primary DB"]
    A --> C["Read Replica"]

    B --> D["UPDATE"]
    B --> E["Replication"]
    E --> C

    C --> F["GET"]

If replication is asynchronous:

UPDATE → Primary
replication
Read Replica

there may be a temporary period where the replica still contains the old data.

Compare:

Primary DB → NEW
Read Replica → OLD

If this occurs, investigate:

  • Replication lag
  • Read routing
  • Transaction commit
  • Read-after-write requirements

Possible solutions include:

  • Route immediate post-write reads to primary
  • Improve replication health
  • Use stronger consistency where justified
  • Implement appropriate read-your-write behavior
  • Retry carefully when eventual consistency is acceptable

Check:

  • Network request/response
  • React state
  • Redux state
  • TanStack Query cache
  • Browser cache
  • Service worker/PWA cache
  • Rendering/derived state logic

Check:

  • Cache HIT/MISS
  • TTL
  • Age
  • Cache key
  • Purge/invalidation events

Check:

  • Request/correlation ID
  • Cache HIT/MISS
  • Cache key
  • Cache TTL
  • Response timestamp
  • Whether DB was queried

Check:

  • Primary vs replica
  • Transaction commit
  • Replication lag
  • Query result
  • Read routing

In production, trace one request through the whole system:

sequenceDiagram
    participant UI as React UI
    participant CDN as CDN
    participant API as API Service
    participant Cache as Redis
    participant DB as Database

    UI->>CDN: GET /customers/123 + request-id
    CDN->>API: Forward request
    API->>Cache: Lookup customer
    Cache-->>API: Cache MISS / HIT
    API->>DB: Query if required
    DB-->>API: Customer data
    API-->>CDN: Response
    CDN-->>UI: Response

Use a request ID such as:

request-id: abc123

Then correlate it across:

  • Browser logs
  • API gateway logs
  • Service logs
  • Cache metrics
  • Database logs

This makes production diagnosis much faster.


Observation Most Likely Cause
API response is fresh, UI is old React state / client cache
Disabling browser cache fixes it Browser cache
CDN says HIT and origin has new data CDN cache
API returns old data and Redis has old value Application/API cache
Primary has new data, replica has old data Database replication lag
All layers have new data but UI is old React/query cache or rendering logic

41. Senior-Level Interview Answer: Stale Data

Section titled “41. Senior-Level Interview Answer: Stale Data”

I would troubleshoot stale data from the outside in, starting with the browser Network tab and tracing the same request through React state, browser cache, CDN, application cache, and finally the database. First, I determine whether the GET API response itself is stale. If the API returns fresh data but the UI is stale, I investigate React state, Redux, or TanStack Query cache. If the API response is stale, I inspect HTTP cache headers and CDN HIT/MISS information, then check application-level caches such as Redis. Finally, I compare the primary database with read replicas to identify replication lag or read-after-write consistency issues. I would use correlation IDs, cache metrics, timestamps, and request/response headers to identify exactly which layer served the stale value.


flowchart TD
    A["React Application"] --> B["Rendering"]
    A --> C["State Management"]
    A --> D["Performance"]
    A --> E["Data Freshness"]

    B --> B1["Render"]
    B --> B2["Virtual DOM / React tree"]
    B --> B3["Reconciliation"]
    B --> B4["Commit"]

    C --> C1["Local State"]
    C --> C2["Context"]
    C --> C3["Redux Toolkit"]
    C --> C4["Zustand"]
    C --> C5["TanStack Query"]

    D --> D1["React.memo"]
    D --> D2["useMemo"]
    D --> D3["useCallback"]
    D --> D4["Localize State"]
    D --> D5["Selectors"]
    D --> D6["Virtualization"]

    E --> E1["React / Query Cache"]
    E --> E2["Browser Cache"]
    E --> E3["CDN Cache"]
    E --> E4["API Cache"]
    E --> E5["DB Replica"]

State / Props / Context
Render
New React tree
Reconciliation
Commit
DOM
React.memo → Component
useMemo → Value
useCallback → Function
Local UI → useState / useReducer
Simple shared → Context
Complex global → Redux Toolkit
Lightweight global → Zustand
Server state → TanStack Query
Profiler
Find bottleneck
Choose targeted optimization
React.memo / useMemo / useCallback
Local state / selectors
Virtualization / lazy loading
UI
React/client cache
Browser cache
CDN
API/application cache
Database
Read replica

A strong senior React engineer should avoid saying:

“I use React.memo, useMemo, and useCallback everywhere for performance.”

A stronger answer is:

“I first identify the source of the problem using profiling and request tracing. I then optimize the specific bottleneck. I keep state close to where it is needed, distinguish client state from server state, use memoization when reference stability or expensive computation makes it valuable, and treat caching as an end-to-end concern across the browser, CDN, application, and database.”