Skip to content

Search and Filter

Search doesn’t have to hit the server. For a list already loaded in memory, filtering client-side is instant and requires no extra API calls. You’ll add a search input above the post feed that narrows the list as the user types.

ApproachWhen to use
Client-side filterSmall to medium lists already in memory
Server-side searchLarge datasets, full-text search, or when data can’t be loaded all at once

Bulletin loads all posts at once, so client-side filtering is the right choice. For thousands of posts, you’d add query params to GET /posts?search=keyword and search in the database.

// src/components/PostFeed.tsx — add search
import { useState, useEffect, useMemo } from 'react'
import PostItem from './PostItem'
import { getPosts, Post } from '../api/postsApi'
export default function PostFeed() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
useEffect(() => {
getPosts()
.then(setPosts)
.catch(() => setError('Failed to load posts.'))
.finally(() => setLoading(false))
}, [])
const filteredPosts = useMemo(() => {
const q = search.toLowerCase().trim()
if (!q) return posts
return posts.filter(p => p.title.toLowerCase().includes(q))
}, [posts, search])
if (loading) return <p>Loading...</p>
if (error) return <p>{error}</p>
return (
<div>
<input
type="search"
placeholder="Search posts..."
value={search}
onChange={e => setSearch(e.target.value)}
aria-label="Search posts"
/>
{filteredPosts.length === 0
? <p>No posts match your search.</p>
: (
<ul>
{filteredPosts.map(post => (
<PostItem key={post.id} post={post} />
))}
</ul>
)
}
</div>
)
}
const filteredPosts = useMemo(() => {
return posts.filter(...)
}, [posts, search])

useMemo caches the result of the filter function and only recomputes it when posts or search changes. Without it, the filter runs on every render — even renders caused by unrelated state changes (like an upvote count updating). For a short list it barely matters, but useMemo makes the intent explicit: this value is derived from these two inputs and nothing else.

You can add a sort dropdown without a server call:

const [sortBy, setSortBy] = useState<'new' | 'top'>('new')
const filteredPosts = useMemo(() => {
const q = search.toLowerCase().trim()
let result = q
? posts.filter(p => p.title.toLowerCase().includes(q))
: [...posts]
if (sortBy === 'top') {
result = result.sort((a, b) => b.upvotes - a.upvotes)
}
return result
}, [posts, search, sortBy])
// in JSX:
<select value={sortBy} onChange={e => setSortBy(e.target.value as 'new' | 'top')}>
<option value="new">Newest</option>
<option value="top">Top</option>
</select>

The API returns posts newest-first by default. Sorting by top is a client-side reorder — no new request needed.

<input value={search} onChange={e => setSearch(e.target.value)} />

This is a controlled input: React owns the value. The input shows exactly what search holds and calls setSearch on every keystroke. Never use defaultValue with a controlled input — pick one approach and stick with it.

Open src/components/PostFeed.tsx in your Bulletin project.

  1. Add search state and a search input above the post list.
  2. Compute filteredPosts with useMemo — filter by title, case-insensitive.
  3. Render filteredPosts instead of posts in the list.
  4. Show “No posts match your search.” when filteredPosts is empty.
  5. Stretch: Add a sort dropdown (Newest / Top) and include sortBy in the useMemo dependency array.
  6. Test: type in the search box and confirm the list narrows live. Clear the input and confirm all posts return.
  • Client-side filtering is instant for lists already in memory — no extra API call.
  • useMemo caches the filtered result and only recomputes when inputs change.
  • Controlled inputs (value + onChange) keep search state in React.
  • Sort can also be client-side when all data is loaded — add it to the same useMemo.