React is the stronger first choice for broad interview preparation, while Svelte is the better choice for a Svelte role or a concise framework-neutral project.
The Short Answer
React's state snapshots, Hook dependencies, immutable updates, and render behavior expose concepts that appear throughout React interview questions. Skip React as the first choice when every relevant role uses Svelte and practice time is limited.
Svelte is a good first choice for a declared Svelte role or a new personal interface where compact component code is valuable. Its compiler turns declarative components into optimized JavaScript, while Svelte 5 runes make state and derived values explicit. Skip Svelte as the primary preparation framework when the interview expects React Hooks, JSX, or React debugging.
Team familiarity overrides a generic recommendation. Practicing the framework used in the interview makes it easier to discuss conventions, testing choices, and mistakes from working code.
Svelte vs React at a Glance
| Decision point | React | Svelte 5 |
|---|---|---|
| Component format | JavaScript or TypeScript functions returning JSX | Components combine script, markup, and optional style blocks |
| Local state | useState returns a snapshot for each render | $state creates reactive state controlled by the compiler |
| Derived values | Calculate during rendering or memoize when measurement supports it | $derived declares a value calculated from reactive inputs |
| Effects | useEffect lists dependencies and may return cleanup | $effect tracks values read synchronously and may return teardown |
| List identity | key on mapped elements | Key expression on an {#each} block |
| Form input | Usually a controlled value and event handler | bind:value or an explicit event handler |
| DOM updating | Rendering calls components, then commit applies the required DOM operations | Compiled code can target updates based on reactive changes |
| Styling | Depends on the project setup, with inline styles available in JSX | A component can contain a scoped <style> block |
| Complete applications | React recommends a framework such as Next.js or React Router | SvelteKit adds routing, data loading, and server rendering |
| Interview use | Best when target listings or exercises use React | Best when the role or take-home task specifies Svelte |
The concepts overlap, but their mental models do not line up perfectly. React state belongs to a render snapshot. A Svelte object or array created with $state can become a deeply reactive proxy, so direct property and array changes can trigger updates.
The Same Interview Component in Both
Use the same acceptance tests for both implementations:
- Adding trimmed text creates one todo, while blank text creates none.
- Changing the filter updates the visible list.
- Toggling a checkbox changes the remaining count.
- Every rendered todo exposes a stable numeric identity.
- Pressing Escape clears the filter.
- Explicitly unmounting the component removes its window listener.
A reproducible baseline uses React 19.1.1, Svelte 5.38.7, Vite 7.1.2, and Playwright 1.55.0. Create Vite projects with the react and svelte templates, install those exact dependency versions, and save the components below as src/TodoInterview.jsx and src/TodoInterview.svelte. Use these mounting entries so the shared suite can explicitly unmount either component:
// react-todo/src/main.jsx
import { createRoot } from 'react-dom/client';
import TodoInterview from './TodoInterview.jsx';
const root = createRoot(document.getElementById('root'));
root.render(<TodoInterview />);
window.__unmount = () => root.unmount();
// svelte-todo/src/main.js
import { mount, unmount } from 'svelte';
import TodoInterview from './TodoInterview.svelte';
const component = mount(TodoInterview, { target: document.getElementById('app') });
window.__unmount = () => unmount(component);
For deliberately incomplete starter variants, copy each component to TodoInterview.starter.jsx or TodoInterview.starter.svelte, point the corresponding entry file at that copy, and replace the add handler with an empty body, the visible list with [], the remaining count with 0, and the Escape effect with an empty effect. The starter still mounts, but the shared tests below fail until those behaviors are implemented. The completed versions follow as reference solutions.
The React version uses a controlled input, functional state updates, derived values, and keyed JSX:
import { useEffect, useRef, useState } from "react";
export default function TodoInterview() {
const nextId = useRef(2);
const [todos, setTodos] = useState([
{ id: 1, text: "Test empty input", done: false }
]);
const [draft, setDraft] = useState("");
const [query, setQuery] = useState("");
const visible = todos.filter(todo =>
todo.text.toLowerCase().includes(query.toLowerCase())
);
const remaining = todos.filter(todo => !todo.done).length;
useEffect(() => {
const clearFilter = event => {
if (event.key === "Escape") setQuery("");
};
window.addEventListener("keydown", clearFilter);
return () => window.removeEventListener("keydown", clearFilter);
}, []);
function addTodo(event) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
setTodos(current => [
...current,
{ id: nextId.current++, text, done: false }
]);
setDraft("");
}
function setDone(id, done) {
setTodos(current =>
current.map(todo => todo.id === id ? { ...todo, done } : todo)
);
}
return (
<section>
<form onSubmit={addTodo}>
<input
aria-label="New todo"
value={draft}
onChange={event => setDraft(event.target.value)}
/>
<button>Add</button>
</form>
<input
aria-label="Filter todos"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<p>{remaining} remaining</p>
<ul>
{visible.map(todo => (
<li key={todo.id} data-todo-id={todo.id}>
<label>
<input
type="checkbox"
checked={todo.done}
onChange={event => setDone(todo.id, event.target.checked)}
/>
{todo.text}
</label>
</li>
))}
</ul>
</section>
);
}
The Svelte version keeps the same behavior with runes, bindings, and a keyed block:
<script>
let nextId = 2;
let todos = $state([
{ id: 1, text: 'Test empty input', done: false }
]);
let draft = $state('');
let query = $state('');
let visible = $derived(
todos.filter(todo =>
todo.text.toLowerCase().includes(query.toLowerCase())
)
);
let remaining = $derived(todos.filter(todo => !todo.done).length);
$effect(() => {
const clearFilter = event => {
if (event.key === 'Escape') query = '';
};
window.addEventListener('keydown', clearFilter);
return () => window.removeEventListener('keydown', clearFilter);
});
function addTodo(event) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
todos.push({ id: nextId++, text, done: false });
draft = '';
}
</script>
<section>
<form onsubmit={addTodo}>
<input aria-label="New todo" bind:value={draft} />
<button>Add</button>
</form>
<input aria-label="Filter todos" bind:value={query} />
<p>{remaining} remaining</p>
<ul>
{#each visible as todo (todo.id)}
<li data-todo-id={todo.id}>
<label>
<input type="checkbox" bind:checked={todo.done} />
{todo.text}
</label>
</li>
{/each}
</ul>
</section>
Save this shared suite as tests/todo.spec.js in a parent directory with @playwright/[email protected] installed:
import { test, expect } from '@playwright/test';
for (const target of [
{ name: 'React', url: 'http://localhost:4173' },
{ name: 'Svelte', url: 'http://localhost:4174' }
]) {
test.describe(target.name, () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const add = window.addEventListener.bind(window);
const remove = window.removeEventListener.bind(window);
window.__keydownListeners = 0;
window.addEventListener = (type, listener, options) => {
if (type === 'keydown') window.__keydownListeners += 1;
return add(type, listener, options);
};
window.removeEventListener = (type, listener, options) => {
if (type === 'keydown') window.__keydownListeners -= 1;
return remove(type, listener, options);
};
});
await page.goto(target.url);
});
test('meets the shared acceptance criteria', async ({ page }) => {
const items = page.locator('li');
await expect(items).toHaveCount(1);
await expect(items.first()).toHaveAttribute('data-todo-id', '1');
await page.getByLabel('New todo').fill(' ');
await page.getByRole('button', { name: 'Add' }).click();
await expect(items).toHaveCount(1);
await page.getByLabel('New todo').fill(' Learn effects ');
await page.getByRole('button', { name: 'Add' }).click();
await expect(items).toHaveCount(2);
await expect(items.nth(0)).toHaveAttribute('data-todo-id', '1');
await expect(items.nth(1)).toHaveAttribute('data-todo-id', '2');
await page.getByLabel('Filter todos').fill('learn');
await expect(items).toHaveCount(1);
await items.getByRole('checkbox').check();
await expect(page.getByText('1 remaining')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByLabel('Filter todos')).toHaveValue('');
await expect(items).toHaveCount(2);
});
test('removes the keydown listener on unmount', async ({ page }) => {
await expect.poll(() => page.evaluate(() => window.__keydownListeners)).toBe(1);
await page.evaluate(() => window.__unmount());
await expect.poll(() => page.evaluate(() => window.__keydownListeners)).toBe(0);
});
});
}
Run the React app with npm run dev -- --port 4173, the Svelte app with npm run dev -- --port 4174, and then run npx playwright install chromium once followed by npx playwright test. Filtering belongs in this rendered-component test because it is inline in both implementations; a separate unit test would require extracting pure filtering logic first.
The Edge Cases Interviewers Actually Test
Each criterion reveals a different interviewer signal:
- Trimmed and blank additions test source-of-truth discipline and input normalization.
- Filtering tests derived-state reasoning rather than duplicated state.
- Toggling and the remaining count test immutable updates in React and proxy mutation in Svelte.
- Stable rendered IDs test reconciliation identity rather than array-position keys.
- Escape handling tests stale-closure and reactive-dependency reasoning.
- Explicit unmounting tests lifecycle ownership and cleanup.
Correctness means observable behavior still matches the acceptance tests after several updates.
React state is a snapshot. Calling setTodos([...todos, item]) twice from the same captured handler can reuse stale todos. The functional form, setTodos(current => ...), receives the state React supplies for that update. Arrays and objects are replaced rather than mutated so React receives a new state value.
Svelte's $state can wrap simple arrays and objects in deeply reactive proxies. todos.push(...) and todo.done = true can therefore update the component. That convenience does not remove the need for stable identity. React's key={todo.id} and Svelte's (todo.id) both prevent an item's identity from depending on its current array position.
The remaining count is derived instead of stored. Duplicating it in state creates another value that every add, toggle, and removal path must keep synchronized. This principle also appears in global state interview exercises.
Effects need equal care. React dependencies describe the reactive values used by an Effect. Svelte $effect tracks reactive values read synchronously while it runs. A Svelte value first read after await or inside a timer is not tracked, so $effect does not remove every dependency bug. Both examples return cleanup for the window listener.
Performance, Tooling, and Production Tradeoffs
Svelte compiles declarative components and can produce targeted updates from reactive changes. React calls component functions during rendering, then commits the DOM operations needed to match the latest output. A React rerender does not replace the entire DOM.
React also has an optional stable compiler that performs automatic memoization of components and Hooks. Its existence corrects the claim that only Svelte has compiler tooling, but it does not make the two rendering models identical.
Avoid fixed claims about speed or bundle size without a measured application. Results depend on the workload, framework versions, production build, browser, device, and measurement method.
For a complete application, compare application frameworks as a separate decision. React recommends starting new applications with a framework, with documented choices including Next.js and React Router. SvelteKit supplies application-level routing, data loading, and server rendering for Svelte projects.
Which One Should You Choose?
Choose React for broad frontend interview preparation when target listings and exercises commonly require it. Choose the declared framework when a role names React or Svelte, since matching the interview environment matters more than a general ranking. For a framework-neutral personal project, choose Svelte when concise local state and templates appeal to you, or React when its component model matches the team and tools you expect to use.
For structured practice across both frameworks, UIReady Premium Annual suits ongoing preparation. The upgrade page lists Monthly, 1-Year, and Lifetime access options.