לדלג לתוכן

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. השלבים: (1) פרקו את ה-UI לקומפוננטות לפי אחריות. (2) בנו גרסה סטטית עם props בלבד. (3) זהו את הסטייט המינימלי - רק מה שלא ניתן לחשב. (4) מקמו כל סטייט באב המשותף הקרוב ביותר. (5) הוסיפו callbacks כדי שילדים ישנו את סטייט האב.

  2. סטייט הוא ערך שנשמר ומשתנה לאורך זמן - למשל filter (מה המשתמש בחר). ערך מחושב נגזר מסטייט קיים - למשל filteredItems שמחושב מ-items ו-filter. אם אפשר לחשב ערך, אל תשמרו אותו בסטייט - זה יוצר כפילות ומגדיל את הסיכוי לבאגים של חוסר סנכרון.

  3. הסטייט חי בקומפוננטת האב המשותפת הקרובה ביותר של כל הקומפוננטות שצריכות אותו. אם רק קומפוננטה אחת צריכה - הסטייט נשאר בה (סטייט מקומי). אם שתי קומפוננטות אחיות צריכות - מרימים לאב.

  4. נתונים בריאקט זורמים רק מלמעלה למטה (props). כשקומפוננטת בן צריכה לשנות סטייט של האב, משתמשים ב"זרימה הפוכה" - האב מעביר פונקציית callback כ-prop, והבן קורא לה כדי לעדכן את הסטייט.

  5. כדאי לפצל כשהקומפוננטה ארוכה מדי (100+ שורות), כשחלק חוזר על עצמו, או כשיש אחריות שונה. לא כדאי לפצל כשזה יוצר יותר מורכבות - למשל אם צריך להעביר 10+ props.