Technical Interview Prep
Intro
Because my career background covers design and code, it can be challenging to prepare for both aspects when it comes to interviewing.
For me, technical interviews are one of the most nerve-wracking parts of the job search because I get so comfortable with my own tools and processes (that may or may not rely on AI) and my muscle memory can stop me from figuring out a solution in a new environment.
This post is a running list of exercises and concepts I revisit when I’m getting ready for the next one.

Fetching Data
One of the most common prompts is fetching data from an API. It’s a great opportunity to show that you understand async flow, loading states, and how a data-fetching library fits in.
Things to be ready to explain:
- How do you handle loading, error, and success states?
- How do you cache data so repeat requests don’t hammer the API?
- How do you prevent too much layout shift while data loads?
A minimal pattern with React Query might look like this:
import { useQuery } from '@tanstack/react-query'
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((res) => res.json()),
})
if (isLoading) return <p>Loading…</p>
if (error) return <p>Something went wrong</p>
return (
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
Worth practicing: swap in isPending vs isLoading (React Query v5), add a skeleton instead of plain text, and explain why queryKey matters for caching and invalidation.
Suspense vs isPending
These get conflated in interviews. The honest answer: if you’re waiting on an API, use isPending. That’s the right tool most of the time.
Reach for React Query + isPending when a component is already mounted and you’re waiting on data:
function AnalyticsChart() {
const { data, isPending, error } = useQuery({
queryKey: ['analytics'],
queryFn: fetchAnalytics,
})
if (isPending) return <ChartSkeleton />
if (error) return <ErrorMessage error={error} />
return <Chart data={data} />
}
function Dashboard() {
return <AnalyticsChart />
}
Dashboard doesn’t need to know anything about loading state — the chart owns its fetch and shows its own skeleton. You could also lift the query into Dashboard and pass data down. Either way, isPending is the right call.
So when does Suspense come in? Only when the thing you’re waiting on isn’t data — it’s code. With React.lazy, the chart component’s JavaScript hasn’t downloaded yet, so there’s nothing mounted to call useQuery inside:
import { lazy, Suspense } from 'react'
// This import() doesn't run until AnalyticsChart renders
const AnalyticsChart = lazy(() => import('./AnalyticsChart'))
function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
)
}
You can’t replace that with isPending unless you manually reimplement what lazy already does — dynamic import(), track whether the module resolved, conditionally render the skeleton yourself. Suspense is React’s built-in version of that.
React Query isPending | Suspense + lazy | |
|---|---|---|
| Waiting on | API data | A JavaScript module / bundle |
| Component mounted? | Yes | Not yet |
| Who shows the skeleton | The component doing the fetch (or its parent) | A parent <Suspense> boundary |
| Error handling | Explicit (error, isError) | Needs a separate error boundary |
They often stack. A lazy-loaded chart might show a Suspense skeleton while its bundle downloads, then show a different skeleton via isPending while it fetches data:
function Dashboard() {
return (
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart /> {/* isPending skeleton lives inside, once the module loads */}
</Suspense>
)
}
Arranging Data
Once data is on the screen, the follow-up is almost always some combination of filtering, sorting, or pagination — without mutating the source array.
Things to practice:
- A toggle that switches between view states (grid vs list, expanded vs compact)
- Sorting alphabetically, ascending, and descending
- Filtering a list based on user input
Sorting is a good warm-up exercise. You want a stable sort that doesn’t mutate the original:
const sorted = [...users].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
)
For toggling views, keep the data layer separate from the presentation layer — fetch once, derive what to show:
const [view, setView] = useState<'grid' | 'list'>('grid')
return (
<>
<button onClick={() => setView((v) => (v === 'grid' ? 'list' : 'grid'))}>
Toggle view
</button>
{view === 'grid' ? <UserGrid users={users} /> : <UserList users={users} />}
</>
)
Arrays and Objects
Arrays — copy before mutating methods
.sort(), .reverse(), and .splice() mutate the array in place. If users comes from props, context, or React Query, mutating it causes bugs:
// Wrong — mutates the original
users.sort((a, b) => a.name.localeCompare(b.name))
// Right — sort a copy
const sorted = [...users].sort((a, b) => a.name.localeCompare(b.name))
.map(), .filter(), and .reduce() already return new arrays, so you usually don’t need spread with them:
// filter already returns a new array — no spread needed
const active = users.filter((u) => u.isActive)
Arrays — add or prepend without mutating
const withNew = [...users, { id: '3', name: 'Grace' }]
const withFirst = [{ id: '0', name: 'Ada' }, ...users]
In React state, same idea — never users.push():
setUsers((prev) => [...prev, newUser])
setUsers((prev) => prev.filter((u) => u.id !== id))
Objects — update a field immutably
Spread is the go-to for setState when you’re updating one field on an object:
setUser((prev) => ({ ...prev, name: 'Grace Hopper' }))
Merge defaults with overrides — later keys win:
const buttonProps = { ...defaultProps, ...props, disabled: isLoading }
Shallow only — know the limit
Spread copies one level deep. Nested objects still share references:
const a = { user: { name: 'Ada' } }
const b = { ...a, user: { ...a.user, name: 'Grace' } } // update nested field
// or use structuredClone(a) when you need a full deep copy
Spread recap
If you’re unsure, ask: “Am I about to mutate something I don’t own?” — props, query cache, or shared state. If yes, copy first with spread (or use a method that returns a new value).
Image Optimization
A common interview prompt: “Render a list of images from an API and lazy-load them.” That usually involves two separate problems — and it’s worth naming both out loud.
- Fetching the list — waiting on an API for image URLs/metadata
- Loading the files — deferring each image download until it’s near the viewport
React Query handles the first. Native lazy loading handles the second. You often use both together.
Be ready to discuss:
- Compression and choosing the right format (WebP, AVIF, JPEG)
- Responsive sizing —
srcset,sizes, or a component that generates variants - Skeleton UI while data loads vs lazy-loading individual image files
While the API request is in flight, show a skeleton grid:
import { useQuery } from '@tanstack/react-query'
function PhotoGrid() {
const { data: photos, isPending, error } = useQuery({
queryKey: ['photos'],
queryFn: () => fetch('/api/photos').then((res) => res.json()),
})
if (isPending) return <PhotoGridSkeleton count={6} />
if (error) return <p>Could not load photos</p>
return (
<ul className="photo-grid">
{photos.map((photo) => (
<li key={photo.id}>
<img
src={photo.url}
alt={photo.alt}
loading="lazy"
decoding="async"
width={photo.width}
height={photo.height}
/>
</li>
))}
</ul>
)
}
Once you have the URLs, loading="lazy" tells the browser to defer downloading off-screen images. No extra React wiring needed — and that’s usually the answer the interviewer is looking for on the lazy-load part.
If they push on layout shift, include explicit width and height (or an aspect-ratio wrapper) so the skeleton grid and the loaded images occupy the same space:
function PhotoGridSkeleton({ count }: { count: number }) {
return (
<ul className="photo-grid" aria-busy="true" aria-label="Loading photos">
{Array.from({ length: count }, (_, i) => (
<li key={i} className="photo-skeleton" />
))}
</ul>
)
}
For responsive images on a single hero or card, reach for srcset and sizes:
<img
src="/images/hero.jpg"
srcSet="/images/hero-640.jpg 640w, /images/hero-1280.jpg 1280w"
sizes="(max-width: 768px) 100vw, 640px"
alt="Product screenshot"
loading="lazy"
decoding="async"
/>
If you’re using a framework, mention what it handles for you (Astro’s image pipeline, Next.js <Image>, etc.) and what you’d still need to think about manually.
Debouncing and Throttling
Search inputs, resize handlers, and autocomplete are classic debounce territory. The interviewer often wants to hear why — not just that you’ve heard the word.
Debouncing waits until the user pauses before firing. Throttling fires at most once per interval, regardless of how many events come in.
function SearchInput() {
const [query, setQuery] = useState('')
useEffect(() => {
const id = setTimeout(() => {
if (query) fetchResults(query)
}, 300)
return () => clearTimeout(id)
}, [query])
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search users…"
/>
)
}
Debouncing is usually the right call when you only care about the final value (search). Throttling is better when you need regular updates but can’t afford to run on every event (scroll position, window resize).
Why Isn’t the Button Working?
This one comes up more often than you’d think. The bug is usually in how the handler is passed to the button.
These are not the same:
// Runs on every render — probably not what you want
<button onClick={handleClick()}>Click me</button>
// Runs only when clicked — correct
<button onClick={handleClick}>Click me</button>
// Also correct — inline arrow function
<button onClick={() => handleClick()}>Click me</button>
State updates have their own gotcha. If the next value depends on the previous one, use the functional updater:
// Stale closure risk when batched or called in quick succession
<button onClick={() => setCount(count + 1)}>{count}</button>
// Safer — always reads the latest previous value
<button onClick={() => setCount((prev) => prev + 1)}>{count}</button>
When debugging, check the component’s API docs too. A <button> inside a form submits by default; a library button might need an explicit type="button".
Conditional Styles
Interviewers often ask how state and props change what a component looks like — and how you’d extend a component without breaking its contract.
Questions to think through:
- How do
disabled,error, orsizeprops map to class names? - How do you merge consumer-supplied classes with internal ones?
- How do you extend types so extra props are allowed without losing autocomplete?
A simple pattern:
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary'
isLoading?: boolean
}
function Button({ variant = 'primary', isLoading, className, ...props }: ButtonProps) {
return (
<button
className={[
'btn',
`btn--${variant}`,
isLoading && 'btn--loading',
className,
]
.filter(Boolean)
.join(' ')}
disabled={isLoading || props.disabled}
{...props}
/>
)
}
Extending native element props (React.ButtonHTMLAttributes<HTMLButtonElement>) is usually better than reinventing onClick, disabled, and aria-* from scratch.
Using Grid and Subgrid
A layout question that comes up in frontend interviews: “You have a row of cards with a title, body, and footer — how do you keep the footers aligned when the body copy varies in length?”
Without a plan, each card is its own island. Short descriptions leave footers floating mid-card; long ones push footers down. You want shared horizontal alignment — all titles on one line, all footers on another.
CSS subgrid (well-supported in modern browsers) lets a child grid inherit its parent’s row tracks, so cards in the same row share alignment:
<ul class="card-grid">
<li class="card">
<h2>Design tokens</h2>
<p>How we name, scale, and ship tokens across products.</p>
<footer><a href="#">Read more</a></footer>
</li>
<li class="card">
<h2>Accessibility</h2>
<p>A longer description that wraps to multiple lines while other cards stay short.</p>
<footer><a href="#">Read more</a></footer>
</li>
</ul>
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1.5rem;
list-style: none;
padding: 0;
}
.card {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3; /* title, body, footer */
gap: 0.75rem;
padding: 1.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
}
Each card spans three implicit rows on the parent grid. The title, body, and footer slot into those shared tracks — footers line up across the row even when body text lengths differ.
- When subgrid fits: repeated components with the same internal structure (cards, list tiles, product grids)
- What
grid-row: span 3does: tells the card how many parent rows to participate in (must match child count) - Fallback: if subgrid isn’t an option, flexbox on each card still works: column layout,
flex: 1on the body,margin-top: autoon the footer
.card--flex {
display: flex;
flex-direction: column;
min-height: 100%;
}
.card--flex .card-body {
flex: 1;
}
.card--flex .card-footer {
margin-top: auto;
}
The flex approach aligns footers within each card’s own height, but cards in a row may still differ in total height unless you also equalize card heights on the parent. Subgrid handles both in one pass.
Container Queries
Media queries respond to the viewport. Container queries respond to a parent element’s size — which matters when the same component shows up in a sidebar, a dashboard grid, and a full-width page.
Register a container on the wrapper, then query it:
<aside class="sidebar">
<article class="card">
<img src="/avatar.jpg" alt="" />
<div class="card-content">
<h2>Jane Doe</h2>
<p>Product designer</p>
</div>
</article>
</aside>
<main class="content">
<article class="card">
<!-- same component, different available width -->
</article>
</main>
.card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.card img {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
/* The parent becomes a query container */
.sidebar,
.content {
container-type: inline-size;
}
@container (min-width: 28rem) {
.card {
flex-direction: row;
align-items: center;
}
.card img {
width: 8rem;
flex-shrink: 0;
}
}
The same .card markup adapts based on how much horizontal space its container has — not the viewport. In a 240px sidebar it stays stacked; in a 600px main column it goes horizontal.
container-type: inline-size: tracks the container’s width (most common). Usesizeif you need height too, but it has stricter layout requirements.- When to reach for it: reusable components in unpredictable layouts (design systems, CMS-driven pages, dashboard widgets)
- When media queries are still right: page-level layout shifts, navigation breakpoints, global typography scale
- Named containers:
container-name: sidebar+@container sidebar (min-width: 20rem)when you have nested containers and need to target a specific ancestor
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
@container sidebar (min-width: 20rem) {
.nav-link span {
display: inline;
}
}
Container queries pair well with the subgrid card example — subgrid handles alignment across a row; container queries handle how each card reflows based on the space it’s given.
Light and Dark Mode
Another common prompt: implement a theme toggle and explain how you’d do it properly in production.
Be ready to cover:
- Design tokens that swap per mode (
--color-bg, not hard-coded hex in components) - Persisting preference in
localStorage(and respectingprefers-color-schemeas the default) - Preventing a flash of the wrong theme on first paint
The FOUC fix is the detail that separates “I made a toggle” from “I’ve shipped this before.” Set the theme class in a blocking script in <head> before the page paints:
<script>
;(function () {
var theme = localStorage.getItem('theme') || 'dark'
if (theme === 'dark') document.documentElement.classList.add('dark')
})()
</script>
Then your toggle just updates the class and writes to storage:
function ThemeToggle() {
function toggle() {
const isDark = document.documentElement.classList.toggle('dark')
localStorage.setItem('theme', isDark ? 'dark' : 'light')
}
return <button onClick={toggle}>Toggle theme</button>
}
Truncating or Modifying Text
Text manipulation seems trivial until someone asks what happens to accessibility and touch targets.
Think through:
- When is truncation a bad idea? (Critical identifiers, error messages, legal copy)
- If space is limited but users need the full text, what do you do? (Tooltip, expandable row,
titleattribute as a last resort)
CSS-only single-line truncation:
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
Multi-line clamp:
.line-clamp {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
If the truncated text is meaningful, pair it with something interactive — a “Show more” control beats hoping users hover long enough to catch a tooltip.
String cheatsheet:
- slice():
string.slice(-1)- returns a new string with the last character - substring():
string.substring(0, 10)- returns a new string with the first 10 characters
Questions
These are a few technical questions I practice talking through about as well:
- When you enter a URL in your browser, what happens?
- How would you explain product design to a five-year-old?
- What design token decisions would you make when building a component?
- When is
asyncused in JavaScript? - What is the difference between a promise and a callback?