Build a small CRUD interface backed entirely by local state: list users, filter them by name, create new ones, edit existing ones, and delete them. There's no server — the users array is the database, and every operation is just an immutable transformation of that array. The one subtlety is that a single form does double duty for both "create" and "edit," distinguished by one extra piece of state.
// A self-contained component. No props.
function App(): JSX.Element;
A form, a filter box, and a list of users with Edit/Delete on each row.
type a name + occupation, click Add → new user appended to the list
click Edit on a row → form fills with that user, button becomes "Save"
click Save → that user is updated in place; form clears
type "ad" in the filter → only users whose name contains "ad" (case-insensitive)
click Delete → that user is removed from the list
editingId === null means "create"; otherwise "edit that id." The submit handler branches on it.users.filter(...) on render.A CRUD screen feels like four separate features, but it's one array and four pure transformations of it — append, map-replace, filter-out, and a derived filtered view. The only stateful trick is letting one form serve both create and edit.
You manage a list of users with no backend. "Create," "update," and "delete" are just different ways of producing the next version of the array from the current one. "Filter" doesn't change the data at all — it's a view computed on render. The part people overthink is the form: rather than building separate "add" and "edit" forms, use the same one and remember which user (if any) you're editing.
State holds the users array plus the UI's working values: the filter text, the form's name and occupation, and editingId. editingId === null means the form is in create mode; a real id means edit mode. Each operation returns a brand-new array so React re-renders:
[...users, newUser]users.map(u => u.id === editingId ? { ...u, ...form } : u)users.filter(u => u.id !== id)users.filter(u => u.name matches filter)A common first attempt mutates users in place and keeps a separate filtered list in state:
function addUser() {
users.push({ id: nextId, name, occupation }); // mutates — no re-render
setFiltered(users.filter(...)); // now two copies to sync
}
Pushing into users doesn't give React a new reference, so it won't re-render; and storing filtered separately means every create/edit/delete has to remember to recompute it. Treating the array as immutable (always replace, never mutate) and deriving the filtered view on render removes both problems.
import { useState } from 'react';
import './styles.css';
type User = { id: number; name: string; occupation: string };
const INITIAL: User[] = [
{ id: 1, name: 'Ada Lovelace', occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', occupation: 'Rear Admiral' },
];
export default function App() {
const [users, setUsers] = useState<User[]>(INITIAL);
const [filter, setFilter] = useState('');
const [name, setName] = useState('');
const [occupation, setOccupation] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const visible = users.filter((u) =>
u.name.toLowerCase().includes(filter.toLowerCase()),
);
function resetForm() {
setName('');
setOccupation('');
setEditingId(null);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
if (editingId === null) {
const nextId = Math.max(0, ...users.map((u) => u.id)) + 1;
setUsers([...users, { id: nextId, name, occupation }]);
} else {
setUsers(
users.map((u) => (u.id === editingId ? { ...u, name, occupation } : u)),
);
}
resetForm();
}
function startEdit(u: User) {
setEditingId(u.id);
setName(u.name);
setOccupation(u.occupation);
}
function remove(id: number) {
setUsers(users.filter((u) => u.id !== id));
if (editingId === id) resetForm();
}
return (
<main className="container">
<h1>Users Database</h1>
<form className="user-form" onSubmit={handleSubmit}>
<input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input
placeholder="Occupation"
value={occupation}
onChange={(e) => setOccupation(e.target.value)}
/>
<div className="form-actions">
<button type="submit">{editingId === null ? 'Add' : 'Save'}</button>
{editingId !== null && (
<button type="button" className="ghost" onClick={resetForm}>
Cancel
</button>
)}
</div>
</form>
<input
className="filter"
placeholder="Filter by name…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul className="user-list">
{visible.map((u) => (
<li key={u.id} className={u.id === editingId ? 'editing' : ''}>
<div className="user-info">
<span className="user-name">{u.name}</span>
<span className="user-occ">{u.occupation}</span>
</div>
<div className="row-actions">
<button className="ghost" onClick={() => startEdit(u)}>
Edit
</button>
<button className="ghost danger" onClick={() => remove(u.id)}>
Delete
</button>
</div>
</li>
))}
{visible.length === 0 && <li className="empty">No users match.</li>}
</ul>
</main>
);
}
visible is derived every render — there's no stored filtered list to keep in sync. handleSubmit branches on editingId: null appends a new user (the id is max(existing ids) + 1, robust to deletions), otherwise map replaces just the edited row immutably. startEdit copies the row into the form fields and records its id, which both switches the button to "Save" and highlights the row. remove filters the user out, and resets the form if you were mid-edit on the deleted user.
Start with the three seed users, editingId = null.
startEdit sets editingId = 3, name = 'Grace Hopper', occupation = 'Rear Admiral'. The form fills, the button reads "Save," a Cancel button appears, and Grace's row gets the editing outline.handleSubmit sees editingId = 3, so it maps: every user passes through unchanged except id 3, which becomes { ...u, name, occupation }. resetForm clears the fields and sets editingId = null.visible recomputes to only users whose name includes "bar" — independent of all the above, because it's derived, not stored.Every write produced a new users array; nothing was mutated, so React re-rendered cleanly each time.
users.push(...) / splice don't change the reference, so React won't re-render. Fix: spread/map/filter to a new array.visible on render.id = users.length + 1. After deletions, lengths collide and produce duplicate ids. Fix: Math.max(...ids) + 1.editingId, submit can't tell create from update. Fix: track the id; null means create.resetForm() when editingId is removed.users to storage so the list survives a reload.A reducer keeps list writes and form mode resets atomic while filtering remains derived.
import { useReducer } from 'react';
import './styles.css';
type User = { id: number; name: string; occupation: string };
type State = {
users: User[];
filter: string;
name: string;
occupation: string;
editingId: number | null;
};
type Action =
| { type: 'field'; field: 'filter' | 'name' | 'occupation'; value: string }
| { type: 'edit'; user: User }
| { type: 'cancel' }
| { type: 'submit' }
| { type: 'remove'; id: number };
const initial: State = {
users: [
{ id: 1, name: 'Ada Lovelace', occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', occupation: 'Rear Admiral' },
],
filter: '', name: '', occupation: '', editingId: null,
};
const clearDraft = (state: State): State => ({
...state, name: '', occupation: '', editingId: null,
});
function reducer(state: State, action: Action): State {
if (action.type === 'field') return { ...state, [action.field]: action.value };
if (action.type === 'edit') return {
...state,
name: action.user.name,
occupation: action.user.occupation,
editingId: action.user.id,
};
if (action.type === 'cancel') return clearDraft(state);
if (action.type === 'remove') {
const next = { ...state, users: state.users.filter((user) => user.id !== action.id) };
return state.editingId === action.id ? clearDraft(next) : next;
}
if (!state.name.trim()) return state;
const users = state.editingId === null
? [...state.users, {
id: Math.max(0, ...state.users.map((user) => user.id)) + 1,
name: state.name,
occupation: state.occupation,
}]
: state.users.map((user) => user.id === state.editingId
? { ...user, name: state.name, occupation: state.occupation }
: user);
return clearDraft({ ...state, users });
}
export default function App() {
const [state, dispatch] = useReducer(reducer, initial);
const visible = state.users.filter((user) =>
user.name.toLowerCase().includes(state.filter.toLowerCase()),
);
return (
<main className="container">
<h1>Users Database</h1>
<form className="user-form" onSubmit={(event) => { event.preventDefault(); dispatch({ type: 'submit' }); }}>
<input placeholder="Name" value={state.name} onChange={(event) => dispatch({ type: 'field', field: 'name', value: event.target.value })} />
<input placeholder="Occupation" value={state.occupation} onChange={(event) => dispatch({ type: 'field', field: 'occupation', value: event.target.value })} />
<div className="form-actions">
<button type="submit">{state.editingId === null ? 'Add' : 'Save'}</button>
{state.editingId !== null && <button type="button" className="ghost" onClick={() => dispatch({ type: 'cancel' })}>Cancel</button>}
</div>
</form>
<input className="filter" placeholder="Filter by name…" value={state.filter} onChange={(event) => dispatch({ type: 'field', field: 'filter', value: event.target.value })} />
<ul className="user-list">
{visible.map((user) => (
<li key={user.id} className={user.id === state.editingId ? 'editing' : ''}>
<div className="user-info"><span className="user-name">{user.name}</span><span className="user-occ">{user.occupation}</span></div>
<div className="row-actions">
<button className="ghost" onClick={() => dispatch({ type: 'edit', user })}>Edit</button>
<button className="ghost danger" onClick={() => dispatch({ type: 'remove', id: user.id })}>Delete</button>
</div>
</li>
))}
{visible.length === 0 && <li className="empty">No users match.</li>}
</ul>
</main>
);
}A custom hook packages the database behavior while the component retains the same rendered structure.
import { useState } from 'react';
import './styles.css';
type User = { id: number; name: string; occupation: string };
const INITIAL: User[] = [
{ id: 1, name: 'Ada Lovelace', occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', occupation: 'Rear Admiral' },
];
function useUsersDatabase() {
const [users, setUsers] = useState(INITIAL);
const [filter, setFilter] = useState('');
const [draft, setDraft] = useState({ name: '', occupation: '', editingId: null as number | null });
const reset = () => setDraft({ name: '', occupation: '', editingId: null });
return {
visible: users.filter((user) => user.name.toLowerCase().includes(filter.toLowerCase())),
filter, setFilter, draft,
setField(field: 'name' | 'occupation', value: string) { setDraft((current) => ({ ...current, [field]: value })); },
edit(user: User) { setDraft({ name: user.name, occupation: user.occupation, editingId: user.id }); },
cancel: reset,
submit() {
if (!draft.name.trim()) return;
setUsers((current) => draft.editingId === null
? [...current, { id: Math.max(0, ...current.map((user) => user.id)) + 1, name: draft.name, occupation: draft.occupation }]
: current.map((user) => user.id === draft.editingId ? { ...user, name: draft.name, occupation: draft.occupation } : user));
reset();
},
remove(id: number) { setUsers((current) => current.filter((user) => user.id !== id)); if (draft.editingId === id) reset(); },
};
}
export default function App() {
const db = useUsersDatabase();
return (
<main className="container">
<h1>Users Database</h1>
<form className="user-form" onSubmit={(event) => { event.preventDefault(); db.submit(); }}>
<input placeholder="Name" value={db.draft.name} onChange={(event) => db.setField('name', event.target.value)} />
<input placeholder="Occupation" value={db.draft.occupation} onChange={(event) => db.setField('occupation', event.target.value)} />
<div className="form-actions">
<button type="submit">{db.draft.editingId === null ? 'Add' : 'Save'}</button>
{db.draft.editingId !== null && <button type="button" className="ghost" onClick={db.cancel}>Cancel</button>}
</div>
</form>
<input className="filter" placeholder="Filter by name…" value={db.filter} onChange={(event) => db.setFilter(event.target.value)} />
<ul className="user-list">
{db.visible.map((user) => (
<li key={user.id} className={user.id === db.draft.editingId ? 'editing' : ''}>
<div className="user-info"><span className="user-name">{user.name}</span><span className="user-occ">{user.occupation}</span></div>
<div className="row-actions"><button className="ghost" onClick={() => db.edit(user)}>Edit</button><button className="ghost danger" onClick={() => db.remove(user.id)}>Delete</button></div>
</li>
))}
{db.visible.length === 0 && <li className="empty">No users match.</li>}
</ul>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a small CRUD interface backed entirely by local state: list users, filter them by name, create new ones, edit existing ones, and delete them. There's no server — the users array is the database, and every operation is just an immutable transformation of that array. The one subtlety is that a single form does double duty for both "create" and "edit," distinguished by one extra piece of state.
// A self-contained component. No props.
function App(): JSX.Element;
A form, a filter box, and a list of users with Edit/Delete on each row.
type a name + occupation, click Add → new user appended to the list
click Edit on a row → form fills with that user, button becomes "Save"
click Save → that user is updated in place; form clears
type "ad" in the filter → only users whose name contains "ad" (case-insensitive)
click Delete → that user is removed from the list
editingId === null means "create"; otherwise "edit that id." The submit handler branches on it.users.filter(...) on render.A CRUD screen feels like four separate features, but it's one array and four pure transformations of it — append, map-replace, filter-out, and a derived filtered view. The only stateful trick is letting one form serve both create and edit.
You manage a list of users with no backend. "Create," "update," and "delete" are just different ways of producing the next version of the array from the current one. "Filter" doesn't change the data at all — it's a view computed on render. The part people overthink is the form: rather than building separate "add" and "edit" forms, use the same one and remember which user (if any) you're editing.
State holds the users array plus the UI's working values: the filter text, the form's name and occupation, and editingId. editingId === null means the form is in create mode; a real id means edit mode. Each operation returns a brand-new array so React re-renders:
[...users, newUser]users.map(u => u.id === editingId ? { ...u, ...form } : u)users.filter(u => u.id !== id)users.filter(u => u.name matches filter)A common first attempt mutates users in place and keeps a separate filtered list in state:
function addUser() {
users.push({ id: nextId, name, occupation }); // mutates — no re-render
setFiltered(users.filter(...)); // now two copies to sync
}
Pushing into users doesn't give React a new reference, so it won't re-render; and storing filtered separately means every create/edit/delete has to remember to recompute it. Treating the array as immutable (always replace, never mutate) and deriving the filtered view on render removes both problems.
import { useState } from 'react';
import './styles.css';
type User = { id: number; name: string; occupation: string };
const INITIAL: User[] = [
{ id: 1, name: 'Ada Lovelace', occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', occupation: 'Rear Admiral' },
];
export default function App() {
const [users, setUsers] = useState<User[]>(INITIAL);
const [filter, setFilter] = useState('');
const [name, setName] = useState('');
const [occupation, setOccupation] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const visible = users.filter((u) =>
u.name.toLowerCase().includes(filter.toLowerCase()),
);
function resetForm() {
setName('');
setOccupation('');
setEditingId(null);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
if (editingId === null) {
const nextId = Math.max(0, ...users.map((u) => u.id)) + 1;
setUsers([...users, { id: nextId, name, occupation }]);
} else {
setUsers(
users.map((u) => (u.id === editingId ? { ...u, name, occupation } : u)),
);
}
resetForm();
}
function startEdit(u: User) {
setEditingId(u.id);
setName(u.name);
setOccupation(u.occupation);
}
function remove(id: number) {
setUsers(users.filter((u) => u.id !== id));
if (editingId === id) resetForm();
}
return (
<main className="container">
<h1>Users Database</h1>
<form className="user-form" onSubmit={handleSubmit}>
<input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<input
placeholder="Occupation"
value={occupation}
onChange={(e) => setOccupation(e.target.value)}
/>
<div className="form-actions">
<button type="submit">{editingId === null ? 'Add' : 'Save'}</button>
{editingId !== null && (
<button type="button" className="ghost" onClick={resetForm}>
Cancel
</button>
)}
</div>
</form>
<input
className="filter"
placeholder="Filter by name…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul className="user-list">
{visible.map((u) => (
<li key={u.id} className={u.id === editingId ? 'editing' : ''}>
<div className="user-info">
<span className="user-name">{u.name}</span>
<span className="user-occ">{u.occupation}</span>
</div>
<div className="row-actions">
<button className="ghost" onClick={() => startEdit(u)}>
Edit
</button>
<button className="ghost danger" onClick={() => remove(u.id)}>
Delete
</button>
</div>
</li>
))}
{visible.length === 0 && <li className="empty">No users match.</li>}
</ul>
</main>
);
}
visible is derived every render — there's no stored filtered list to keep in sync. handleSubmit branches on editingId: null appends a new user (the id is max(existing ids) + 1, robust to deletions), otherwise map replaces just the edited row immutably. startEdit copies the row into the form fields and records its id, which both switches the button to "Save" and highlights the row. remove filters the user out, and resets the form if you were mid-edit on the deleted user.
Start with the three seed users, editingId = null.
startEdit sets editingId = 3, name = 'Grace Hopper', occupation = 'Rear Admiral'. The form fills, the button reads "Save," a Cancel button appears, and Grace's row gets the editing outline.handleSubmit sees editingId = 3, so it maps: every user passes through unchanged except id 3, which becomes { ...u, name, occupation }. resetForm clears the fields and sets editingId = null.visible recomputes to only users whose name includes "bar" — independent of all the above, because it's derived, not stored.Every write produced a new users array; nothing was mutated, so React re-rendered cleanly each time.
users.push(...) / splice don't change the reference, so React won't re-render. Fix: spread/map/filter to a new array.visible on render.id = users.length + 1. After deletions, lengths collide and produce duplicate ids. Fix: Math.max(...ids) + 1.editingId, submit can't tell create from update. Fix: track the id; null means create.resetForm() when editingId is removed.users to storage so the list survives a reload.A reducer keeps list writes and form mode resets atomic while filtering remains derived.
import { useReducer } from 'react';
import './styles.css';
type User = { id: number; name: string; occupation: string };
type State = {
users: User[];
filter: string;
name: string;
occupation: string;
editingId: number | null;
};
type Action =
| { type: 'field'; field: 'filter' | 'name' | 'occupation'; value: string }
| { type: 'edit'; user: User }
| { type: 'cancel' }
| { type: 'submit' }
| { type: 'remove'; id: number };
const initial: State = {
users: [
{ id: 1, name: 'Ada Lovelace', occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', occupation: 'Rear Admiral' },
],
filter: '', name: '', occupation: '', editingId: null,
};
const clearDraft = (state: State): State => ({
...state, name: '', occupation: '', editingId: null,
});
function reducer(state: State, action: Action): State {
if (action.type === 'field') return { ...state, [action.field]: action.value };
if (action.type === 'edit') return {
...state,
name: action.user.name,
occupation: action.user.occupation,
editingId: action.user.id,
};
if (action.type === 'cancel') return clearDraft(state);
if (action.type === 'remove') {
const next = { ...state, users: state.users.filter((user) => user.id !== action.id) };
return state.editingId === action.id ? clearDraft(next) : next;
}
if (!state.name.trim()) return state;
const users = state.editingId === null
? [...state.users, {
id: Math.max(0, ...state.users.map((user) => user.id)) + 1,
name: state.name,
occupation: state.occupation,
}]
: state.users.map((user) => user.id === state.editingId
? { ...user, name: state.name, occupation: state.occupation }
: user);
return clearDraft({ ...state, users });
}
export default function App() {
const [state, dispatch] = useReducer(reducer, initial);
const visible = state.users.filter((user) =>
user.name.toLowerCase().includes(state.filter.toLowerCase()),
);
return (
<main className="container">
<h1>Users Database</h1>
<form className="user-form" onSubmit={(event) => { event.preventDefault(); dispatch({ type: 'submit' }); }}>
<input placeholder="Name" value={state.name} onChange={(event) => dispatch({ type: 'field', field: 'name', value: event.target.value })} />
<input placeholder="Occupation" value={state.occupation} onChange={(event) => dispatch({ type: 'field', field: 'occupation', value: event.target.value })} />
<div className="form-actions">
<button type="submit">{state.editingId === null ? 'Add' : 'Save'}</button>
{state.editingId !== null && <button type="button" className="ghost" onClick={() => dispatch({ type: 'cancel' })}>Cancel</button>}
</div>
</form>
<input className="filter" placeholder="Filter by name…" value={state.filter} onChange={(event) => dispatch({ type: 'field', field: 'filter', value: event.target.value })} />
<ul className="user-list">
{visible.map((user) => (
<li key={user.id} className={user.id === state.editingId ? 'editing' : ''}>
<div className="user-info"><span className="user-name">{user.name}</span><span className="user-occ">{user.occupation}</span></div>
<div className="row-actions">
<button className="ghost" onClick={() => dispatch({ type: 'edit', user })}>Edit</button>
<button className="ghost danger" onClick={() => dispatch({ type: 'remove', id: user.id })}>Delete</button>
</div>
</li>
))}
{visible.length === 0 && <li className="empty">No users match.</li>}
</ul>
</main>
);
}A custom hook packages the database behavior while the component retains the same rendered structure.
import { useState } from 'react';
import './styles.css';
type User = { id: number; name: string; occupation: string };
const INITIAL: User[] = [
{ id: 1, name: 'Ada Lovelace', occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', occupation: 'Rear Admiral' },
];
function useUsersDatabase() {
const [users, setUsers] = useState(INITIAL);
const [filter, setFilter] = useState('');
const [draft, setDraft] = useState({ name: '', occupation: '', editingId: null as number | null });
const reset = () => setDraft({ name: '', occupation: '', editingId: null });
return {
visible: users.filter((user) => user.name.toLowerCase().includes(filter.toLowerCase())),
filter, setFilter, draft,
setField(field: 'name' | 'occupation', value: string) { setDraft((current) => ({ ...current, [field]: value })); },
edit(user: User) { setDraft({ name: user.name, occupation: user.occupation, editingId: user.id }); },
cancel: reset,
submit() {
if (!draft.name.trim()) return;
setUsers((current) => draft.editingId === null
? [...current, { id: Math.max(0, ...current.map((user) => user.id)) + 1, name: draft.name, occupation: draft.occupation }]
: current.map((user) => user.id === draft.editingId ? { ...user, name: draft.name, occupation: draft.occupation } : user));
reset();
},
remove(id: number) { setUsers((current) => current.filter((user) => user.id !== id)); if (draft.editingId === id) reset(); },
};
}
export default function App() {
const db = useUsersDatabase();
return (
<main className="container">
<h1>Users Database</h1>
<form className="user-form" onSubmit={(event) => { event.preventDefault(); db.submit(); }}>
<input placeholder="Name" value={db.draft.name} onChange={(event) => db.setField('name', event.target.value)} />
<input placeholder="Occupation" value={db.draft.occupation} onChange={(event) => db.setField('occupation', event.target.value)} />
<div className="form-actions">
<button type="submit">{db.draft.editingId === null ? 'Add' : 'Save'}</button>
{db.draft.editingId !== null && <button type="button" className="ghost" onClick={db.cancel}>Cancel</button>}
</div>
</form>
<input className="filter" placeholder="Filter by name…" value={db.filter} onChange={(event) => db.setFilter(event.target.value)} />
<ul className="user-list">
{db.visible.map((user) => (
<li key={user.id} className={user.id === db.draft.editingId ? 'editing' : ''}>
<div className="user-info"><span className="user-name">{user.name}</span><span className="user-occ">{user.occupation}</span></div>
<div className="row-actions"><button className="ghost" onClick={() => db.edit(user)}>Edit</button><button className="ghost danger" onClick={() => db.remove(user.id)}>Delete</button></div>
</li>
))}
{db.visible.length === 0 && <li className="empty">No users match.</li>}
</ul>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.