Articles
A blog’s article list and article page. Filter by category, and click a card to open the article.
Blog
Key points
- 1
Remember only the id of the open article
Store the open article’s id in state, not the article itself. Look up its content from the list with find. Even if the list changes, it stays in sync.
- 2
Return the detail view first when an article is open
Write if (opened) { return detail } first, then the list after it. One component can switch between two screens.
- 3
Show readers what to read next
Below the article, show other articles in the same category. Use filter to pick ones in the same category, excluding the current article.
Tools used
Click to see the comparison in Part 2
Edit the code
Edit the code and the preview on the right updates.
import { useState } from "react" import { ArrowLeftIcon, ClockIcon } from "lucide-react" import { Badge } from "./components/ui/badge" import { Button } from "./components/ui/button" // The article list (in a real app, this comes from a server or a CMS) const articles = [ { id: 1, title: "Write useState with 3 questions", category: "React", date: "2026-10-02", minutes: 5, color: "from-sky-400 to-blue-600", excerpt: "Answer “What changes?”, “Which state?”, and “What's the new value?” and you can write the line that updates state.", body: [ "When you're stuck on the line that updates state, answer 3 questions in order.", "First: what on the screen do you want to change? Second: which state remembers it? Third: what is the new value?", "Once you have the answers, just write setSomething(newValue).", ], }, { id: 2, title: "Keep spacing in multiples of 4", category: "Design", date: "2026-09-28", minutes: 3, color: "from-violet-400 to-fuchsia-500", excerpt: "Using only fixed steps like 4, 8, 12, 16… makes the whole screen feel consistent.", body: [ "If you pick spacing on a whim, things drift little by little and the screen looks messy.", "In Tailwind, p-1 is 4px and p-2 is 8px. Stick to these steps and you'll naturally land on multiples of 4.", ], }, { id: 3, title: "map vs. filter", category: "React", date: "2026-09-20", minutes: 4, color: "from-cyan-400 to-sky-600", excerpt: "map rewrites each item. filter decides what to keep. Tell them apart by whether the count changes.", body: [ "map makes a new list where each item has been turned into something else. The count stays the same.", "filter makes a new list with only the items that match a condition. The count may go down.", ], }, { id: 4, title: "Your first portfolio with no experience", category: "Career", date: "2026-09-12", minutes: 6, color: "from-amber-400 to-orange-500", excerpt: "What you were thinking matters more than how many projects you have. Even one project you can explain is strong.", body: [ "Rather than building a lot, it matters more to be able to explain why you built one project the way you did.", "Add short notes to your screenshots about what you worked on and what you struggled with.", ], }, { id: 5, title: "Limit yourself to 3 colors", category: "Design", date: "2026-09-05", minutes: 3, color: "from-rose-400 to-pink-600", excerpt: "A main color, an accent color, and a text color. These 3 are enough for most screens.", body: [ "The more colors you add, the harder it is to know where to look.", "First pick one main color, then use the accent color only where you want emphasis.", ], }, { id: 6, title: "Tips for studying 30 minutes every day", category: "Career", date: "2026-08-30", minutes: 4, color: "from-emerald-400 to-teal-600", excerpt: "Decide where to stop before deciding when to study. Stop where the next step is clear, and you can start right away the next day.", body: [ "A common reason people quit is having to figure out what to do each time they start.", "End each day by writing a one-line note about what to do next, and the next day you just start from there.", ], }, ] const categories = ["All", "React", "Design", "Career"] // The article's colored banner (instead of a photo) function Cover({ article, className }) { return <div className={"bg-linear-to-br " + article.color + " " + className} /> } export default function App() { // ① The selected category (starts as "All"), kept in state //const category = "All" const [category, setCategory] = useState("All") // ① The id of the open article (null while viewing the list), kept in state //const openId = null const [openId, setOpenId] = useState(null) // ⑤ The open article: find the article in articles whose id is openId (find) //const opened = undefined const opened = articles.find((article) => article.id === openId) //const opened = undefined //const shown = articles.filter((article) => category === "All" || article.category === category) // ⑥ When an article is open, return the detail page first // The condition: is there an open article? if (opened) { // Other articles in the same category const related = articles.filter((article) => article.category === opened.category && article.id !== opened.id) return ( <div className="mx-auto my-6 w-full max-w-2xl px-4 text-foreground"> {/* ⑦ On click, go back to the list (set the open article to none) */} <Button variant="ghost" size="sm" className="-ml-2" onClick={() => setOpenId(null)} > <ArrowLeftIcon /> Back to articles </Button> <Cover article={opened} className="mt-4 h-48 rounded-xl" /> <div className="mt-6 flex items-center gap-3 text-xs text-muted-foreground"> <Badge variant="secondary">{opened.category}</Badge> <span>{opened.date}</span> <span className="flex items-center gap-1"> <ClockIcon className="size-3" /> {opened.minutes} min read </span> </div> <h1 className="mt-3 text-2xl font-bold tracking-tight">{opened.title}</h1> <div className="mt-5 flex flex-col gap-4 leading-relaxed text-foreground/80"> {opened.body.map((paragraph) => ( <p key={paragraph}>{paragraph}</p> ))} </div> {related.length > 0 && ( <div className="mt-10 border-t pt-6"> <p className="text-sm font-semibold">More in this category</p> <div className="mt-3 flex flex-col gap-2"> {related.map((article) => ( <button key={article.id} type="button" onClick={() => setOpenId(article.id)} className="flex items-center gap-3 rounded-lg p-2 text-left text-sm hover:bg-muted" > <Cover article={article} className="size-10 shrink-0 rounded-md" /> {article.title} </button> ))} </div> </div> )} </div> ) } // ③ Articles to show: only the selected category (all of them for "All") const shown = articles.filter((article) => category === "All" || article.category === category) //const filtered = products.filter((product) => product.name.includes(query) && (category === "All" || product.category === category)) return ( <div className="mx-auto my-6 w-full max-w-4xl px-4 text-foreground"> <h1 className="text-xl font-semibold">Blog</h1> <div className="mt-4 flex flex-wrap gap-2"> {/* // ② On click, select that category. The selected button is filled (default), the others are outline */} { categories.map((name) => ( <Button key={name} size="sm" variant={category === name ? "default" : "outline"} onClick={() => setCategory(name)}> {name} </Button> )) } </div> <div className="mt-6 grid gap-5 sm:grid-cols-2 lg:grid-cols-3"> {/* ④ List shown with map. On click, open that article */} {shown.map((article) => ( <button key={article.id} type="button" className="group flex flex-col overflow-hidden rounded-xl border bg-card text-left transition hover:-translate-y-0.5 hover:shadow-md" onClick={() => setOpenId(article.id)} > <Cover article={article} className="h-32" /> <div className="flex flex-1 flex-col p-4"> <div className="flex items-center gap-2 text-xs text-muted-foreground"> <Badge variant="secondary">{article.category}</Badge> <span>{article.date}</span> </div> <p className="mt-2 font-semibold leading-snug group-hover:text-primary">{article.title}</p> <p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{article.excerpt}</p> </div> </button> ))} </div> </div > ) }