What you'll build: A search bar and species filter buttons that update the animal list in real time as the user types or clicks.
Time: 60 minutes
Reset command: npm run section:2:start
In Section 1, the component only needed to display data it was given. Now we need components that remember things — like what the user has typed, or which filter button is active.
React calls this state. State is data that:
- Lives inside a component
- Can change over time
- Causes the component to re-render when it changes
The useState hook gives you a state variable and a function to update it:
import { useState } from 'react'
const [count, setCount] = useState(0)
// ^value ^setter ^initial valueWhen you call setCount(1), React re-renders the component with count === 1. The key rule: never mutate state directly. Always use the setter function.
// Wrong — React won't notice this change:
count = count + 1
// Correct:
setCount(count + 1)Open src/App.tsx. Add a state variable for the search query at the top of the function:
import { useState } from 'react'
import type { Species } from '@/types/animal'
const [search, setSearch] = useState('')Now add a second state variable for the selected species filter:
const [selectedSpecies, setSelectedSpecies] = useState<Species | null>(null)
nullas initial value:nullmeans "no filter selected" — all species should show. The typeSpecies | nulltells TypeScript this variable can hold either a species string or null.
Here's an important principle: don't store derived data in state.
The filtered list isn't a separate piece of data — it's a result of the full list and the current filter values. Calculate it fresh on every render:
const filteredAnimals = sampleAnimals
.filter((a) => selectedSpecies === null || a.species === selectedSpecies)
.filter((a) => {
const q = search.toLowerCase()
return q === '' || a.name.toLowerCase().includes(q) || a.breed.toLowerCase().includes(q)
})Why not
useStatefor the filtered list? If you stored it in state you'd need to keep two pieces of state in sync — the filter value AND the result. That's a bug waiting to happen. Derive it instead and it's always correct.
Pass filteredAnimals to PetList instead of sampleAnimals:
<PetList animals={filteredAnimals} />The filtering logic is in place — now you need UI components to drive those state values.
Create src/components/SearchBar.tsx. This is a controlled input — meaning React is the single source of truth for the input's value:
interface Props {
value: string
onChange: (value: string) => void
}
export function SearchBar({ value, onChange }: Props) {
return (
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search by name or breed..."
/>
)
}Controlled vs uncontrolled: A controlled input has its value driven by React state —
value={value}. An uncontrolled input lets the browser manage the value. We use controlled inputs so the parent component always knows exactly what's typed, which lets us filter immediately on every keystroke.
Add some Tailwind styling to the input:
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
Import and render SearchBar in App.tsx, passing the state and setter:
import { SearchBar } from '@/components/SearchBar'
// Inside <main>:
<SearchBar value={search} onChange={setSearch} />Save and try it — type something in the search box. The animal count should update immediately. If you type "max" only Max's card should remain.
Create src/components/FilterBar.tsx. This renders a row of buttons, one for each species option:
import type { Species } from '@/types/animal'
const FILTERS: Array<{ label: string; value: Species | null }> = [
{ label: 'All', value: null },
{ label: 'Dogs', value: 'dog' },
{ label: 'Cats', value: 'cat' },
{ label: 'Rabbits', value: 'rabbit' },
{ label: 'Other', value: 'other' },
]
interface Props {
selected: Species | null
onChange: (species: Species | null) => void
}
export function FilterBar({ selected, onChange }: Props) {
return (
<div>
{FILTERS.map((f) => (
<button key={f.label} onClick={() => onChange(f.value)}>
{f.label}
</button>
))}
</div>
)
}To show which filter is active, style the button differently when selected === f.value. Use a conditional class:
className={selected === f.value ? 'bg-green-600 text-white' : 'bg-white text-gray-600 border border-gray-300'}Add rounded-full px-4 py-1.5 text-sm font-medium transition-colors to both variants for base styling.
import { FilterBar } from '@/components/FilterBar'
// Inside <main>, alongside SearchBar:
<FilterBar selected={selectedSpecies} onChange={setSelectedSpecies} />Arrange them side by side:
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="flex-1">
<SearchBar value={search} onChange={setSearch} />
</div>
<FilterBar selected={selectedSpecies} onChange={setSelectedSpecies} />
</div>Add a results counter below the filters:
<p className="text-sm text-gray-500 mb-6">
{filteredAnimals.length} animal{filteredAnimals.length !== 1 ? 's' : ''} found
</p>Try each of these and verify the result:
| Action | Expected result |
|---|---|
| Type "max" in the search bar | Only Max's card visible, counter shows "1 animal found" |
| Click "Cats" filter | Only Whiskers visible, counter shows "1 animal found" |
| Click "Rabbits" | Only Thumper visible |
| Type "golden" with "Cats" selected | Counter shows "0 animals found", empty state message shows |
| Click "All" | All 3 animals visible, counter shows "3 animals found" |
| Clear the search box | Previous filter still active |
- Add a clear button — show an
×button next to the search input when there is a query; clicking it callssetSearch('') - Add size filters — add a second FilterBar for size (small / medium / large)
- Highlight the matched text — in PetCard, wrap the matched portion of the name in a
<mark>tag when there's an active search - Keyboard shortcut — press
/to focus the search input. UseuseRefto get a reference to the input element and call.focus()on it - URL state — instead of
useState, try encoding the search and filter in the URL query string usingURLSearchParams. Refresh the page — does the filter persist?
The app works but all the components are flat in src/components/. In Section 3 you'll reorganise them into a proper Atomic Design hierarchy and build a Storybook component library.
Reference solution:
npm run section:2:complete— or opensections/section-2/complete/to read without affecting your files.