A curated collection of questions designed to help developers prepare for technical interviews, focusing on core concepts and real-world scenarios.
useMemo memoizes a computed value and returns it.
useCallback memoizes a function reference.
| Hook | Use when |
|---|---|
useMemo | You want to avoid expensive recalculations |
useCallback | You want to prevent unnecessary re-renders caused by new function references |
// useMemo - memoizes a value const sortedList = useMemo(() => { return items.sort((a, b) => a.price - b.price); }, [items]); // useCallback - memoizes a function const handleClick = useCallback(() => { doSomething(id); }, [id]);
Note: don't overuse these hooks - they have their own overhead. Apply only where a profiler shows a real problem.
Virtual DOM is a lightweight in-memory representation of the real DOM tree as JavaScript objects.
Real DOM operations are expensive. Manipulating JS objects is faster, so batching and the diff algorithm provide a noticeable performance boost.
React 18+ uses Concurrent Mode where updates can be interrupted and deferred - this is the next level of optimization on top of Virtual DOM.
Render -> DOM mutations -> useLayoutEffect -> Browser paints -> useEffect
useEffect | useLayoutEffect | |
|---|---|---|
| When | After paint (async) | Before paint (sync) |
| Blocks browser | No | Yes |
| SSR | Works correctly | Produces a warning |
useLayoutEffectgetBoundingClientRect).useLayoutEffect(() => { const { height } = ref.current.getBoundingClientRect(); setHeight(height); // before paint - user won't see a jump }, []);
Rule: use
useEffectby default. Switch touseLayoutEffectonly when you see visible visual artifacts.
Fiber is a complete rewrite of React's internal rendering engine (introduced in React 16).
The previous reconciler traversed the component tree recursively and synchronously - it could not be interrupted. With a large tree this blocked the main thread and caused UI jank.
High priority: user input, animations
Low priority: data fetching renders, transitions
Summary: Fiber is the architectural foundation for
Suspense,Transitions,useDeferredValue, and all of Concurrent React.
Context lets you pass data through the component tree without prop drilling.
const ThemeContext = createContext('light'); function App() { return ( <ThemeContext.Provider value="dark"> <Child /> </ThemeContext.Provider> ); } function Child() { const theme = useContext(ThemeContext); return <div>{theme}</div>; }
Every context consumer re-renders on any change to value - even if it only uses part of the data.
value with useMemo.Context works great for rarely changing data: theme, locale, authentication.
RSC are components that render exclusively on the server and send no JavaScript to the client.
| Server Component | Client Component | |
|---|---|---|
| Rendering | Server | Client + Server (hydration) |
| JS in bundle | No | Yes |
Hooks (useState etc.) | No | Yes |
| Direct DB/FS access | Yes | No |
| Interactivity | No | Yes |
// ServerComponent.tsx - no 'use client' async function ProductPage({ id }: { id: string }) { const product = await db.products.findById(id); // directly on the server return <ProductCard product={product} />; }
The
'use client'directive turns a component into a Client Component. In Next.js App Router all components are Server Components by default.
React compares the old and new virtual trees using two rules:
When rendering lists React uses key to identify elements between renders.
// Bad - React may mix up elements items.map((item, index) => <Item key={index} {...item} />) // Good - stable unique identifier items.map((item) => <Item key={item.id} {...item} />)
keyis also used as an intentional state reset - changing a component'skeyfully recreates it.
Suspense lets components wait for something (data, code) and show a fallback in the meantime.
const HeavyChart = lazy(() => import('./HeavyChart')); function Dashboard() { return ( <Suspense fallback={<Spinner />}> <HeavyChart /> </Suspense> ); }
A library (React Query, Relay, Next.js) must implement the Suspense protocol: throw a promise during render if data is not ready yet. React catches it and shows the fallback.
// Conceptually inside a library: function useSuspenseQuery(key) { if (!cache.has(key)) throw fetchData(key); // React catches this promise return cache.get(key); }
<ErrorBoundary fallback={<Error />}> <Suspense fallback={<Skeleton />}> <UserProfile /> </Suspense> </ErrorBoundary>
React 19 introduced the
use(promise)hook - the official way to integrate promises with Suspense.
The field value is stored in React state. React fully controls the form.
function ControlledInput() { const [value, setValue] = useState(''); return ( <input value={value} onChange={(e) => setValue(e.target.value)} /> ); }
The value is stored in the DOM. Read via ref.
function UncontrolledInput() { const ref = useRef<HTMLInputElement>(null); const handleSubmit = () => { console.log(ref.current?.value); }; return <input ref={ref} defaultValue="default" />; }
| Controlled | Uncontrolled | |
|---|---|---|
| Live validation | Yes | No |
| Dependent fields | Yes | No |
| Simple forms | Overkill | Yes |
| Non-React integration | Hard | Yes |
Most React forms are controlled. Uncontrolled is used for file inputs and integration with third-party DOM libraries.
Batching is the merging of multiple setState calls into a single re-render for optimization.
Batching only worked inside React event handlers. Inside setTimeout, Promise, or native events - each setState triggered a separate re-render.
// React 17 - 2 re-renders (inside setTimeout) setTimeout(() => { setCount(c => c + 1); // re-render setFlag(f => !f); // re-render }, 1000);
Now batching works everywhere automatically.
// React 18 - 1 re-render (even inside setTimeout) setTimeout(() => { setCount(c => c + 1); setFlag(f => !f); // one re-render }, 1000);
import { flushSync } from 'react-dom'; flushSync(() => setCount(c => c + 1)); // immediate re-render flushSync(() => setFlag(f => !f)); // second immediate re-render
flushSyncis rarely used - for example when integrating with animation libraries or when you need to synchronously read the DOM after an update.
Both hooks manage local state, but for different scenarios.
const [count, setCount] = useState(0); setCount(count + 1);
Best for simple, independent values.
type Action = { type: 'increment' } | { type: 'reset' }; function reducer(state: number, action: Action): number { switch (action.type) { case 'increment': return state + 1; case 'reset': return 0; } } const [count, dispatch] = useReducer(reducer, 0); dispatch({ type: 'increment' });
useReducer+Contextis a lightweight alternative to Redux for medium-sized apps.
A portal lets you render a child component into a different DOM node that is outside the parent component's hierarchy.
import { createPortal } from 'react-dom'; function Modal({ children }: { children: React.ReactNode }) { return createPortal( <div className="modal">{children}</div>, document.getElementById('modal-root')! ); }
z-index and overflow: hidden issues from parents.Even though a Portal renders its element elsewhere in the DOM:
<!-- HTML structure --> <div id="app">...</div> <div id="modal-root"> <!-- Portal renders here --> </div>
Code splitting is breaking the bundle into pieces that load on demand (lazy loading).
import { lazy, Suspense } from 'react'; const SettingsPage = lazy(() => import('./SettingsPage')); function App() { return ( <Suspense fallback={<PageSkeleton />}> <SettingsPage /> </Suspense> ); }
const routes = [ { path: '/', element: lazy(() => import('./HomePage')) }, { path: '/dashboard', element: lazy(() => import('./DashboardPage')) }, ];
| Tool | Mechanism |
|---|---|
| Webpack | import() - automatic chunks |
| Vite | ESM-based splitting out of the box |
| Next.js | Automatic per page + dynamic() |
const Chart = lazy(() => import(/* webpackChunkName: "chart" */ './Chart') );
Goal: send the user only the code needed for the current screen.
Difficulty
Tags
Check your knowledge with interactive questions and instant feedback.