6.9 חשיבה בריאקט פתרון
פתרון - חשיבה בריאקט¶
פתרון תרגיל 1¶
עץ קומפוננטות:
ShopApp
SearchBar
CategoryFilter (dropdown)
SidebarFilters
PriceRangeFilter
RatingFilter
InStockCheckbox
ProductGrid
ProductCard
ShoppingCart
CartItem
CartSummary
סטייט מינימלי:
searchText(מחרוזת) - טקסט החיפושselectedCategory(מחרוזת) - קטגוריה נבחרתpriceRange(מערך [min, max]) - טווח מחיריםminRating(מספר) - דירוג מינימליinStockOnly(בוליאני) - במלאי בלבדcartItems(מערך של{productId, quantity}) - עגלת קניות
מה לא סטייט: רשימת המוצרים המסוננת (חישוב), סכום כולל (חישוב מהעגלה), מספר פריטים בעגלה (חישוב).
היכן חי הסטייט: הכל ב-ShopApp כי SearchBar, SidebarFilters, ו-ProductGrid כולם צריכים גישה לסינון, ו-ProductCard ו-ShoppingCart צריכים גישה לעגלה.
callbacks: onSearchChange, onCategoryChange, onPriceChange, onRatingChange, onStockFilterChange, onAddToCart, onRemoveFromCart, onUpdateQuantity.
פתרון תרגיל 2¶
import { useState } from "react";
// types
interface Contact {
id: number;
name: string;
phone: string;
email: string;
group: "friends" | "family" | "work";
}
type ContactGroup = Contact["group"] | "all";
// ContactCard
interface ContactCardProps {
contact: Contact;
onEdit: (contact: Contact) => void;
onDelete: (id: number) => void;
}
function ContactCard({ contact, onEdit, onDelete }: ContactCardProps) {
return (
<div style={{ border: "1px solid #ddd", padding: "12px", margin: "8px 0", borderRadius: "8px" }}>
<h3>{contact.name}</h3>
<p>Phone: {contact.phone}</p>
<p>Email: {contact.email}</p>
<p>Group: {contact.group}</p>
<button onClick={() => onEdit(contact)}>Edit</button>
<button onClick={() => onDelete(contact.id)} style={{ marginLeft: "8px" }}>Delete</button>
</div>
);
}
// SearchBar
interface SearchBarProps {
search: string;
group: ContactGroup;
onSearchChange: (text: string) => void;
onGroupChange: (group: ContactGroup) => void;
}
function SearchBar({ search, group, onSearchChange, onGroupChange }: SearchBarProps) {
return (
<div style={{ display: "flex", gap: "8px", marginBottom: "16px" }}>
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search by name..."
style={{ flex: 1, padding: "8px" }}
/>
<select value={group} onChange={(e) => onGroupChange(e.target.value as ContactGroup)}>
<option value="all">All Groups</option>
<option value="friends">Friends</option>
<option value="family">Family</option>
<option value="work">Work</option>
</select>
</div>
);
}
// AddContactForm
interface AddContactFormProps {
editingContact: Contact | null;
onSave: (contact: Omit<Contact, "id">) => void;
onUpdate: (contact: Contact) => void;
onCancel: () => void;
}
function AddContactForm({ editingContact, onSave, onUpdate, onCancel }: AddContactFormProps) {
const [form, setForm] = useState({
name: editingContact?.name ?? "",
phone: editingContact?.phone ?? "",
email: editingContact?.email ?? "",
group: editingContact?.group ?? "friends" as Contact["group"],
});
// sync form when editingContact changes
useState(() => {
if (editingContact) {
setForm({
name: editingContact.name,
phone: editingContact.phone,
email: editingContact.email,
group: editingContact.group,
});
}
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!form.name.trim()) return;
if (editingContact) {
onUpdate({ ...editingContact, ...form });
} else {
onSave(form);
}
setForm({ name: "", phone: "", email: "", group: "friends" });
};
return (
<form onSubmit={handleSubmit} style={{ marginBottom: "16px", padding: "16px", border: "1px solid #ddd", borderRadius: "8px" }}>
<h3>{editingContact ? "Edit Contact" : "Add Contact"}</h3>
<input name="name" value={form.name} onChange={handleChange} placeholder="Name" />
<input name="phone" value={form.phone} onChange={handleChange} placeholder="Phone" />
<input name="email" value={form.email} onChange={handleChange} placeholder="Email" type="email" />
<select name="group" value={form.group} onChange={handleChange}>
<option value="friends">Friends</option>
<option value="family">Family</option>
<option value="work">Work</option>
</select>
<button type="submit">{editingContact ? "Update" : "Add"}</button>
{editingContact && (
<button type="button" onClick={onCancel} style={{ marginLeft: "8px" }}>Cancel</button>
)}
</form>
);
}
// ContactList
interface ContactListProps {
contacts: Contact[];
onEdit: (contact: Contact) => void;
onDelete: (id: number) => void;
}
function ContactList({ contacts, onEdit, onDelete }: ContactListProps) {
if (contacts.length === 0) {
return <p>No contacts found</p>;
}
return (
<div>
{contacts.map((contact) => (
<ContactCard
key={contact.id}
contact={contact}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
);
}
// ContactApp
function ContactApp() {
const [contacts, setContacts] = useState<Contact[]>([]);
const [nextId, setNextId] = useState(1);
const [search, setSearch] = useState("");
const [group, setGroup] = useState<ContactGroup>("all");
const [editingContact, setEditingContact] = useState<Contact | null>(null);
const addContact = (data: Omit<Contact, "id">) => {
setContacts([...contacts, { ...data, id: nextId }]);
setNextId(nextId + 1);
};
const updateContact = (updated: Contact) => {
setContacts(contacts.map((c) => (c.id === updated.id ? updated : c)));
setEditingContact(null);
};
const deleteContact = (id: number) => {
setContacts(contacts.filter((c) => c.id !== id));
if (editingContact?.id === id) setEditingContact(null);
};
// computed - not state
const filteredContacts = contacts
.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()))
.filter((c) => group === "all" || c.group === group);
return (
<div style={{ maxWidth: "600px", margin: "0 auto" }}>
<h1>Contacts ({contacts.length})</h1>
<AddContactForm
editingContact={editingContact}
onSave={addContact}
onUpdate={updateContact}
onCancel={() => setEditingContact(null)}
/>
<SearchBar
search={search}
group={group}
onSearchChange={setSearch}
onGroupChange={setGroup}
/>
<ContactList
contacts={filteredContacts}
onEdit={setEditingContact}
onDelete={deleteContact}
/>
</div>
);
}
ניתוח הסטייט:
contacts- סטייט, ב-ContactApp (כולם צריכים)search- סטייט, ב-ContactApp (SearchBar ו-ContactList צריכים)group- סטייט, ב-ContactApp (SearchBar ו-ContactList צריכים)editingContact- סטייט, ב-ContactApp (AddContactForm ו-ContactCard צריכים)filteredContacts- מחושב, לא סטייטformב-AddContactForm - סטייט מקומי (רק הטופס צריך)
פתרון תרגיל 3¶
import { useState } from "react";
// Modal
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) return null;
return (
<div
onClick={onClose}
style={{
position: "fixed", top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: "rgba(0,0,0,0.5)", display: "flex",
justifyContent: "center", alignItems: "center",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: "white", padding: "24px",
borderRadius: "8px", minWidth: "300px", maxWidth: "500px",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: "16px" }}>
<h2>{title}</h2>
<button onClick={onClose}>X</button>
</div>
<div>{children}</div>
</div>
</div>
);
}
// Tabs
interface Tab {
label: string;
content: React.ReactNode;
}
function Tabs({ tabs }: { tabs: Tab[] }) {
const [activeIndex, setActiveIndex] = useState(0);
return (
<div>
<div style={{ display: "flex", borderBottom: "2px solid #ddd" }}>
{tabs.map((tab, i) => (
<button
key={i}
onClick={() => setActiveIndex(i)}
style={{
padding: "8px 16px",
border: "none",
borderBottom: i === activeIndex ? "2px solid blue" : "none",
fontWeight: i === activeIndex ? "bold" : "normal",
cursor: "pointer",
}}
>
{tab.label}
</button>
))}
</div>
<div style={{ padding: "16px" }}>{tabs[activeIndex].content}</div>
</div>
);
}
// Accordion
interface AccordionItem {
title: string;
content: React.ReactNode;
}
function Accordion({ items }: { items: AccordionItem[] }) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const toggle = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div>
{items.map((item, i) => (
<div key={i} style={{ border: "1px solid #ddd", marginBottom: "4px" }}>
<button
onClick={() => toggle(i)}
style={{
width: "100%", padding: "12px", textAlign: "left",
border: "none", cursor: "pointer",
fontWeight: openIndex === i ? "bold" : "normal",
}}
>
{openIndex === i ? "- " : "+ "}
{item.title}
</button>
{openIndex === i && (
<div style={{ padding: "12px", borderTop: "1px solid #ddd" }}>
{item.content}
</div>
)}
</div>
))}
</div>
);
}
// App - demonstrating all components
function App() {
const [modalOpen, setModalOpen] = useState(false);
return (
<div style={{ padding: "20px", maxWidth: "600px", margin: "0 auto" }}>
<h1>Component Demo</h1>
<h2>Modal</h2>
<button onClick={() => setModalOpen(true)}>Open Modal</button>
<Modal isOpen={modalOpen} onClose={() => setModalOpen(false)} title="Example Modal">
<p>This is the modal content. Click the overlay or X to close.</p>
</Modal>
<h2>Tabs</h2>
<Tabs
tabs={[
{ label: "Tab 1", content: <p>Content for tab 1</p> },
{ label: "Tab 2", content: <p>Content for tab 2</p> },
{ label: "Tab 3", content: <p>Content for tab 3</p> },
]}
/>
<h2>Accordion</h2>
<Accordion
items={[
{ title: "Section 1", content: <p>Details for section 1</p> },
{ title: "Section 2", content: <p>Details for section 2</p> },
{ title: "Section 3", content: <p>Details for section 3</p> },
]}
/>
</div>
);
}
שלוש הקומפוננטות משתמשות ב-composition - הן מקבלות children או content כ-ReactNode. זה מאפשר גמישות מלאה בתוכן שמוצג בפנים.
פתרון תרגיל 4¶
import { useState } from "react";
// types
interface Post {
id: number;
title: string;
content: string;
category: "sale" | "jobs" | "housing" | "services";
date: string;
author: string;
}
type SortOrder = "newest" | "oldest";
// PostCard
interface PostCardProps {
post: Post;
onDelete: (id: number) => void;
}
function PostCard({ post, onDelete }: PostCardProps) {
return (
<div style={{ border: "1px solid #ddd", padding: "16px", margin: "8px 0", borderRadius: "8px" }}>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<h3>{post.title}</h3>
<span style={{ color: "#888", fontSize: "12px" }}>{post.category}</span>
</div>
<p>{post.content}</p>
<div style={{ display: "flex", justifyContent: "space-between", color: "#888", fontSize: "14px" }}>
<span>By {post.author} - {post.date}</span>
<button onClick={() => onDelete(post.id)}>Delete</button>
</div>
</div>
);
}
// PostForm
interface PostFormProps {
onAdd: (post: Omit<Post, "id" | "date">) => void;
}
function PostForm({ onAdd }: PostFormProps) {
const [form, setForm] = useState({
title: "",
content: "",
category: "sale" as Post["category"],
author: "",
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!form.title.trim() || !form.content.trim() || !form.author.trim()) return;
onAdd(form);
setForm({ title: "", content: "", category: "sale", author: "" });
};
return (
<form onSubmit={handleSubmit} style={{ marginBottom: "16px", padding: "16px", border: "1px solid #ddd", borderRadius: "8px" }}>
<h3>New Post</h3>
<input name="title" value={form.title} onChange={handleChange} placeholder="Title" style={{ width: "100%", marginBottom: "8px" }} />
<textarea name="content" value={form.content} onChange={handleChange} placeholder="Content" rows={3} style={{ width: "100%", marginBottom: "8px" }} />
<input name="author" value={form.author} onChange={handleChange} placeholder="Your name" style={{ marginBottom: "8px" }} />
<select name="category" value={form.category} onChange={handleChange}>
<option value="sale">Sale</option>
<option value="jobs">Jobs</option>
<option value="housing">Housing</option>
<option value="services">Services</option>
</select>
<button type="submit" style={{ marginLeft: "8px" }}>Post</button>
</form>
);
}
// FilterControls
interface FilterControlsProps {
search: string;
category: string;
sortOrder: SortOrder;
categoryCounts: Record<string, number>;
onSearchChange: (text: string) => void;
onCategoryChange: (category: string) => void;
onSortChange: (order: SortOrder) => void;
}
function FilterControls({
search, category, sortOrder, categoryCounts,
onSearchChange, onCategoryChange, onSortChange,
}: FilterControlsProps) {
return (
<div style={{ display: "flex", gap: "8px", marginBottom: "16px", flexWrap: "wrap" }}>
<input
type="text"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search..."
style={{ flex: 1, padding: "8px" }}
/>
<select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
<option value="all">All ({Object.values(categoryCounts).reduce((a, b) => a + b, 0)})</option>
<option value="sale">Sale ({categoryCounts["sale"] ?? 0})</option>
<option value="jobs">Jobs ({categoryCounts["jobs"] ?? 0})</option>
<option value="housing">Housing ({categoryCounts["housing"] ?? 0})</option>
<option value="services">Services ({categoryCounts["services"] ?? 0})</option>
</select>
<select value={sortOrder} onChange={(e) => onSortChange(e.target.value as SortOrder)}>
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
</select>
</div>
);
}
// BulletinBoard
function BulletinBoard() {
const [posts, setPosts] = useState<Post[]>([]);
const [nextId, setNextId] = useState(1);
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
const addPost = (data: Omit<Post, "id" | "date">) => {
const newPost: Post = {
...data,
id: nextId,
date: new Date().toLocaleDateString(),
};
setPosts([newPost, ...posts]);
setNextId(nextId + 1);
};
const deletePost = (id: number) => {
setPosts(posts.filter((p) => p.id !== id));
};
// computed values
const categoryCounts: Record<string, number> = {};
for (const post of posts) {
categoryCounts[post.category] = (categoryCounts[post.category] ?? 0) + 1;
}
const filteredPosts = posts
.filter((p) => {
const matchesSearch =
p.title.toLowerCase().includes(search.toLowerCase()) ||
p.content.toLowerCase().includes(search.toLowerCase());
const matchesCategory = category === "all" || p.category === category;
return matchesSearch && matchesCategory;
})
.sort((a, b) => {
if (sortOrder === "newest") return b.id - a.id;
return a.id - b.id;
});
return (
<div style={{ maxWidth: "700px", margin: "0 auto" }}>
<h1>Bulletin Board</h1>
<PostForm onAdd={addPost} />
<FilterControls
search={search}
category={category}
sortOrder={sortOrder}
categoryCounts={categoryCounts}
onSearchChange={setSearch}
onCategoryChange={setCategory}
onSortChange={setSortOrder}
/>
{filteredPosts.length === 0 ? (
<p>No posts found</p>
) : (
filteredPosts.map((post) => (
<PostCard key={post.id} post={post} onDelete={deletePost} />
))
)}
</div>
);
}
פתרון תרגיל 5¶
הקומפוננטה השטוחה צריכה להתפצל. הנה ה-interfaces ומבנה הקומפוננטות:
import { useState } from "react";
// types
interface Item {
id: number;
text: string;
}
type FilterType = "all" | "short" | "long";
// AddItemForm - manages its own input state
interface AddItemFormProps {
onAdd: (text: string) => void;
}
function AddItemForm({ onAdd }: AddItemFormProps) {
const [input, setInput] = useState("");
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (input.trim()) {
onAdd(input.trim());
setInput("");
}
};
return (
<form onSubmit={handleSubmit}>
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Add item..." />
<button type="submit">Add</button>
</form>
);
}
// FilterButtons
interface FilterButtonsProps {
filter: FilterType;
onFilterChange: (filter: FilterType) => void;
}
function FilterButtons({ filter, onFilterChange }: FilterButtonsProps) {
const filters: FilterType[] = ["all", "short", "long"];
return (
<div>
{filters.map((f) => (
<button
key={f}
onClick={() => onFilterChange(f)}
style={{ fontWeight: filter === f ? "bold" : "normal" }}
>
{f}
</button>
))}
</div>
);
}
// ItemRow
interface ItemRowProps {
item: Item;
isEditing: boolean;
editText: string;
onEditStart: (item: Item) => void;
onEditChange: (text: string) => void;
onEditSave: () => void;
onEditCancel: () => void;
onDelete: (id: number) => void;
}
function ItemRow({
item, isEditing, editText,
onEditStart, onEditChange, onEditSave, onEditCancel, onDelete,
}: ItemRowProps) {
if (isEditing) {
return (
<li>
<input value={editText} onChange={(e) => onEditChange(e.target.value)} />
<button onClick={onEditSave}>Save</button>
<button onClick={onEditCancel}>Cancel</button>
</li>
);
}
return (
<li>
{item.text}
<button onClick={() => onEditStart(item)}>Edit</button>
<button onClick={() => onDelete(item.id)}>Delete</button>
</li>
);
}
// ItemList
interface ItemListProps {
items: Item[];
editId: number | null;
editText: string;
onEditStart: (item: Item) => void;
onEditChange: (text: string) => void;
onEditSave: () => void;
onEditCancel: () => void;
onDelete: (id: number) => void;
}
function ItemList({
items, editId, editText,
onEditStart, onEditChange, onEditSave, onEditCancel, onDelete,
}: ItemListProps) {
return (
<ul>
{items.map((item) => (
<ItemRow
key={item.id}
item={item}
isEditing={editId === item.id}
editText={editText}
onEditStart={onEditStart}
onEditChange={onEditChange}
onEditSave={onEditSave}
onEditCancel={onEditCancel}
onDelete={onDelete}
/>
))}
</ul>
);
}
// App - holds shared state, passes down props and callbacks
function App() {
const [items, setItems] = useState<Item[]>([]);
const [filter, setFilter] = useState<FilterType>("all");
const [editId, setEditId] = useState<number | null>(null);
const [editText, setEditText] = useState("");
const [nextId, setNextId] = useState(1);
const addItem = (text: string) => {
setItems([...items, { id: nextId, text }]);
setNextId(nextId + 1);
};
const deleteItem = (id: number) => {
setItems(items.filter((item) => item.id !== id));
};
const startEdit = (item: Item) => {
setEditId(item.id);
setEditText(item.text);
};
const saveEdit = () => {
if (editId === null) return;
setItems(items.map((item) => (item.id === editId ? { ...item, text: editText } : item)));
setEditId(null);
setEditText("");
};
const cancelEdit = () => {
setEditId(null);
setEditText("");
};
// computed
const filteredItems = items.filter((item) => {
if (filter === "short") return item.text.length <= 10;
if (filter === "long") return item.text.length > 10;
return true;
});
return (
<div>
<h1>Items ({items.length})</h1>
<AddItemForm onAdd={addItem} />
<FilterButtons filter={filter} onFilterChange={setFilter} />
<ItemList
items={filteredItems}
editId={editId}
editText={editText}
onEditStart={startEdit}
onEditChange={setEditText}
onEditSave={saveEdit}
onEditCancel={cancelEdit}
onDelete={deleteItem}
/>
</div>
);
}
ה-input של הטופס הפך לסטייט מקומי של AddItemForm. שאר הסטייט נשאר ב-App כי כמה קומפוננטות צריכות אותו.
תשובות לשאלות¶
-
השלבים: (1) פרקו את ה-UI לקומפוננטות לפי אחריות. (2) בנו גרסה סטטית עם props בלבד. (3) זהו את הסטייט המינימלי - רק מה שלא ניתן לחשב. (4) מקמו כל סטייט באב המשותף הקרוב ביותר. (5) הוסיפו callbacks כדי שילדים ישנו את סטייט האב.
-
סטייט הוא ערך שנשמר ומשתנה לאורך זמן - למשל
filter(מה המשתמש בחר). ערך מחושב נגזר מסטייט קיים - למשלfilteredItemsשמחושב מ-itemsו-filter. אם אפשר לחשב ערך, אל תשמרו אותו בסטייט - זה יוצר כפילות ומגדיל את הסיכוי לבאגים של חוסר סנכרון. -
הסטייט חי בקומפוננטת האב המשותפת הקרובה ביותר של כל הקומפוננטות שצריכות אותו. אם רק קומפוננטה אחת צריכה - הסטייט נשאר בה (סטייט מקומי). אם שתי קומפוננטות אחיות צריכות - מרימים לאב.
-
נתונים בריאקט זורמים רק מלמעלה למטה (props). כשקומפוננטת בן צריכה לשנות סטייט של האב, משתמשים ב"זרימה הפוכה" - האב מעביר פונקציית callback כ-prop, והבן קורא לה כדי לעדכן את הסטייט.
-
כדאי לפצל כשהקומפוננטה ארוכה מדי (100+ שורות), כשחלק חוזר על עצמו, או כשיש אחריות שונה. לא כדאי לפצל כשזה יוצר יותר מורכבות - למשל אם צריך להעביר 10+ props.