Skip to content

Latest commit

 

History

History
234 lines (165 loc) · 7.27 KB

File metadata and controls

234 lines (165 loc) · 7.27 KB

Section 2: State & Interactivity

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


The big idea

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 value

When 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)

Step 1 — Add a search state to App.tsx

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)

null as initial value: null means "no filter selected" — all species should show. The type Species | null tells TypeScript this variable can hold either a species string or null.


Step 2 — Derive the filtered list

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 useState for 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.


Step 3 — Build the SearchBar component

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"

Step 4 — Wire SearchBar into App.tsx

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.


Step 5 — Build the FilterBar component

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.


Step 6 — Wire FilterBar into App.tsx

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>

Check your work

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

Going further

  1. Add a clear button — show an × button next to the search input when there is a query; clicking it calls setSearch('')
  2. Add size filters — add a second FilterBar for size (small / medium / large)
  3. Highlight the matched text — in PetCard, wrap the matched portion of the name in a <mark> tag when there's an active search
  4. Keyboard shortcut — press / to focus the search input. Use useRef to get a reference to the input element and call .focus() on it
  5. URL state — instead of useState, try encoding the search and filter in the URL query string using URLSearchParams. Refresh the page — does the filter persist?

What's next

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 open sections/section-2/complete/ to read without affecting your files.