לדלג לתוכן

6.9 חשיבה בריאקט הרצאה

חשיבה בריאקט - Thinking in React

עד עכשיו למדנו את אבני הבניין של ריאקט - קומפוננטות, props, סטייט, אירועים, רשימות, טפסים, ו-useEffect. עכשיו הזמן ללמוד איך לחשוב בריאקט - התהליך של בניית אפליקציה מאפס.

הגישה של ריאקט מבוססת על חמישה שלבים:

  1. פירוק ה-UI לקומפוננטות
  2. בניית גרסה סטטית
  3. זיהוי הסטייט המינימלי
  4. קביעת היכן הסטייט חי
  5. הוספת זרימת נתונים הפוכה

שלב 1 - פירוק ה-UI לקומפוננטות

הצעד הראשון הוא להסתכל על העיצוב (mockup) ולצייר מלבנים סביב כל קומפוננטה. כל מלבן הוא קומפוננטה פוטנציאלית.

כללים לפירוק:

  • עיקרון האחריות הבודדת (Single Responsibility) - כל קומפוננטה עושה דבר אחד. אם היא גדלה, פצלו אותה
  • התאמה לנתונים - קומפוננטות נוטות להתאים למבנה הנתונים. כל רמה בנתונים בדרך כלל מתאימה לקומפוננטה
  • שימוש חוזר - אם חלק מופיע כמה פעמים, הוא קומפוננטה נפרדת

דוגמה - אפליקציית רשימת מוצרים:

FilterableProductTable
  SearchBar
  ProductTable
    ProductCategoryRow
    ProductRow

כל רמה בעץ מתאימה לרמה בנתונים: הטבלה מכילה קטגוריות, כל קטגוריה מכילה מוצרים.

שלב 2 - בניית גרסה סטטית

בנו גרסה שמציגה את ה-UI בלי אינטראקטיביות. שלב זה דורש הרבה כתיבה ומעט חשיבה:

interface Product {
    category: string;
    price: string;
    stocked: boolean;
    name: string;
}

function ProductRow({ product }: { product: Product }) {
    const nameStyle = product.stocked ? {} : { color: "red" };

    return (
        <tr>
            <td style={nameStyle}>{product.name}</td>
            <td>{product.price}</td>
        </tr>
    );
}

function ProductCategoryRow({ category }: { category: string }) {
    return (
        <tr>
            <th colSpan={2}>{category}</th>
        </tr>
    );
}

function ProductTable({ products }: { products: Product[] }) {
    const categories = [...new Set(products.map((p) => p.category))];

    return (
        <table>
            <thead>
                <tr>
                    <th>Name</th>
                    <th>Price</th>
                </tr>
            </thead>
            <tbody>
                {categories.map((category) => (
                    <React.Fragment key={category}>
                        <ProductCategoryRow category={category} />
                        {products
                            .filter((p) => p.category === category)
                            .map((product) => (
                                <ProductRow key={product.name} product={product} />
                            ))}
                    </React.Fragment>
                ))}
            </tbody>
        </table>
    );
}

function SearchBar() {
    return (
        <div>
            <input type="text" placeholder="Search..." />
            <label>
                <input type="checkbox" /> Only show products in stock
            </label>
        </div>
    );
}

function FilterableProductTable({ products }: { products: Product[] }) {
    return (
        <div>
            <SearchBar />
            <ProductTable products={products} />
        </div>
    );
}

כללים בגרסה הסטטית:

  • השתמשו רק ב-props, לא ב-state
  • העבירו נתונים רק מלמעלה למטה
  • אל תדאגו לאינטראקטיביות - זה בא בשלב הבא

שלב 3 - זיהוי הסטייט המינימלי

עכשיו צריך לזהות מה צריך להיות סטייט. העיקרון: מצאו את הסט המינימלי של סטייט שמתאר את כל ה-UI.

שאלות לזיהוי סטייט:

  • האם הערך משתנה לאורך זמן? אם לא, זה לא סטייט
  • האם הערך מועבר מאב דרך props? אם כן, זה לא סטייט
  • האם אפשר לחשב את הערך מסטייט או props קיימים? אם כן, זה לא סטייט

בדוגמה שלנו:

נתון סטייט? למה?
רשימת המוצרים לא מגיע כ-props
טקסט החיפוש כן משתנה, לא ניתן לחישוב
ערך ה-checkbox כן משתנה, לא ניתן לחישוב
הרשימה המסוננת לא ניתן לחישוב מהמוצרים + החיפוש + ה-checkbox

הסטייט המינימלי: טקסט חיפוש + ערך checkbox. זה הכל.

שלב 4 - היכן הסטייט חי

אחרי שזיהינו את הסטייט, צריך להחליט באיזו קומפוננטה הוא יחיה.

הכלל: הסטייט חי בקומפוננטת האב המשותפת הקרובה ביותר של כל הקומפוננטות שצריכות אותו.

בדוגמה:
- SearchBar צריכה את טקסט החיפוש ואת ה-checkbox (כדי להציג אותם)
- ProductTable צריכה את טקסט החיפוש ואת ה-checkbox (כדי לסנן)
- האב המשותף: FilterableProductTable

function FilterableProductTable({ products }: { products: Product[] }) {
    const [filterText, setFilterText] = useState("");
    const [inStockOnly, setInStockOnly] = useState(false);

    return (
        <div>
            <SearchBar
                filterText={filterText}
                inStockOnly={inStockOnly}
            />
            <ProductTable
                products={products}
                filterText={filterText}
                inStockOnly={inStockOnly}
            />
        </div>
    );
}

שלב 5 - זרימת נתונים הפוכה - inverse data flow

הנתונים זורמים מלמעלה למטה דרך props. אבל SearchBar צריכה לשנות את הסטייט שחי ב-FilterableProductTable. הפתרון: העברת פונקציות callback כ-props:

interface SearchBarProps {
    filterText: string;
    inStockOnly: boolean;
    onFilterTextChange: (text: string) => void;
    onInStockOnlyChange: (checked: boolean) => void;
}

function SearchBar({
    filterText,
    inStockOnly,
    onFilterTextChange,
    onInStockOnlyChange,
}: SearchBarProps) {
    return (
        <div>
            <input
                type="text"
                value={filterText}
                onChange={(e) => onFilterTextChange(e.target.value)}
                placeholder="Search..."
            />
            <label>
                <input
                    type="checkbox"
                    checked={inStockOnly}
                    onChange={(e) => onInStockOnlyChange(e.target.checked)}
                />
                Only show products in stock
            </label>
        </div>
    );
}

והאב מעביר את הפונקציות:

function FilterableProductTable({ products }: { products: Product[] }) {
    const [filterText, setFilterText] = useState("");
    const [inStockOnly, setInStockOnly] = useState(false);

    return (
        <div>
            <SearchBar
                filterText={filterText}
                inStockOnly={inStockOnly}
                onFilterTextChange={setFilterText}
                onInStockOnlyChange={setInStockOnly}
            />
            <ProductTable
                products={products}
                filterText={filterText}
                inStockOnly={inStockOnly}
            />
        </div>
    );
}

דפוסי הרכבה - composition patterns

ילדים כ-props - children

במקום ליצור קומפוננטה שמכילה הכל, אפשר לתת לקומפוננטת האב להחליט מה להציג בפנים:

interface CardProps {
    title: string;
    children: React.ReactNode;
}

function Card({ title, children }: CardProps) {
    return (
        <div style={{ border: "1px solid #ddd", borderRadius: "8px", padding: "16px" }}>
            <h3>{title}</h3>
            <div>{children}</div>
        </div>
    );
}

// usage - the parent decides what goes inside
function App() {
    return (
        <Card title="User Info">
            <p>Alice, age 30</p>
            <button>Edit</button>
        </Card>
    );
}

פרופס כ-slots

כשצריכים יותר "חורים" מאשר רק children:

interface LayoutProps {
    header: React.ReactNode;
    sidebar: React.ReactNode;
    children: React.ReactNode;
}

function Layout({ header, sidebar, children }: LayoutProps) {
    return (
        <div>
            <header>{header}</header>
            <div style={{ display: "flex" }}>
                <aside style={{ width: "200px" }}>{sidebar}</aside>
                <main style={{ flex: 1 }}>{children}</main>
            </div>
        </div>
    );
}

function App() {
    return (
        <Layout
            header={<h1>My App</h1>}
            sidebar={<nav>Navigation here</nav>}
        >
            <p>Main content here</p>
        </Layout>
    );
}

הפרדת לוגיקה מ-UI

כשקומפוננטה מכילה גם לוגיקה מורכבת וגם UI, אפשר לפצל:

// logic
function useUsers() {
    const [users, setUsers] = useState<User[]>([]);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        fetch("https://jsonplaceholder.typicode.com/users")
            .then((res) => res.json())
            .then((data) => {
                setUsers(data);
                setLoading(false);
            });
    }, []);

    return { users, loading };
}

// UI
function UserList() {
    const { users, loading } = useUsers();

    if (loading) return <p>Loading...</p>;

    return (
        <ul>
            {users.map((user) => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
}

זה נקרא custom hook - פונקציה שמתחילה ב-use ומכילה לוגיקה עם hooks. נלמד על זה יותר בהמשך.

טיפים מעשיים

מתי לפצל קומפוננטה

  • כשהקומפוננטה עוברת 100+ שורות
  • כשיש חלק שחוזר על עצמו
  • כשיש חלק עם אחריות שונה (למשל header ו-content)
  • כשרוצים לשתף חלק מה-UI בין מסכים

מתי לא לפצל

  • כשהפיצול יוצר יותר מורכבות מאשר הוא פותר
  • כשהחלק משמש רק במקום אחד ויש לו 20 שורות
  • כשהפיצול ידרוש להעביר 10+ props

כללי אצבע לסטייט

  • התחילו בלי סטייט. הוסיפו רק כשצריך אינטראקטיביות
  • העדיפו חישוב על סטייט - אם אפשר לחשב ערך, אל תשמרו אותו בסטייט
  • סטייט מינימלי - שמרו רק את מה שאי אפשר לחשב
  • הרימו סטייט רק כשצריך - אם רק קומפוננטה אחת משתמשת, הסטייט נשאר שם

דוגמה מלאה - רשימת משימות

בואו ניישם את כל החמישה שלבים על אפליקציית משימות:

שלב 1 - קומפוננטות:

TodoApp
  AddTodoForm
  FilterBar
  TodoList
    TodoItem
  TodoStats

שלב 2 + 3 + 4 + 5 - מימוש מלא:

import { useState } from "react";

// types
interface Todo {
    id: number;
    text: string;
    done: boolean;
}

type Filter = "all" | "active" | "completed";

// TodoItem
interface TodoItemProps {
    todo: Todo;
    onToggle: (id: number) => void;
    onDelete: (id: number) => void;
}

function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
    return (
        <li style={{ display: "flex", justifyContent: "space-between", padding: "8px" }}>
            <label style={{ textDecoration: todo.done ? "line-through" : "none" }}>
                <input
                    type="checkbox"
                    checked={todo.done}
                    onChange={() => onToggle(todo.id)}
                />
                {todo.text}
            </label>
            <button onClick={() => onDelete(todo.id)}>Delete</button>
        </li>
    );
}

// AddTodoForm
interface AddTodoFormProps {
    onAdd: (text: string) => void;
}

function AddTodoForm({ onAdd }: AddTodoFormProps) {
    const [text, setText] = useState("");

    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();
        if (text.trim()) {
            onAdd(text.trim());
            setText("");
        }
    };

    return (
        <form onSubmit={handleSubmit}>
            <input
                type="text"
                value={text}
                onChange={(e) => setText(e.target.value)}
                placeholder="Add todo..."
            />
            <button type="submit">Add</button>
        </form>
    );
}

// FilterBar
interface FilterBarProps {
    filter: Filter;
    onFilterChange: (filter: Filter) => void;
}

function FilterBar({ filter, onFilterChange }: FilterBarProps) {
    const filters: Filter[] = ["all", "active", "completed"];

    return (
        <div>
            {filters.map((f) => (
                <button
                    key={f}
                    onClick={() => onFilterChange(f)}
                    style={{ fontWeight: filter === f ? "bold" : "normal" }}
                >
                    {f}
                </button>
            ))}
        </div>
    );
}

// TodoStats
function TodoStats({ todos }: { todos: Todo[] }) {
    const total = todos.length;
    const completed = todos.filter((t) => t.done).length;
    const active = total - completed;

    return (
        <p>
            {total} total, {active} active, {completed} completed
        </p>
    );
}

// TodoApp - the main component that holds all state
function TodoApp() {
    const [todos, setTodos] = useState<Todo[]>([]);
    const [filter, setFilter] = useState<Filter>("all");
    const [nextId, setNextId] = useState(1);

    const addTodo = (text: string) => {
        setTodos([...todos, { id: nextId, text, done: false }]);
        setNextId(nextId + 1);
    };

    const toggleTodo = (id: number) => {
        setTodos(todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
    };

    const deleteTodo = (id: number) => {
        setTodos(todos.filter((t) => t.id !== id));
    };

    // filtered list is computed, not state!
    const filteredTodos = todos.filter((todo) => {
        if (filter === "active") return !todo.done;
        if (filter === "completed") return todo.done;
        return true;
    });

    return (
        <div style={{ maxWidth: "400px", margin: "0 auto" }}>
            <h1>Todo App</h1>
            <AddTodoForm onAdd={addTodo} />
            <FilterBar filter={filter} onFilterChange={setFilter} />
            <ul style={{ listStyle: "none", padding: 0 }}>
                {filteredTodos.map((todo) => (
                    <TodoItem
                        key={todo.id}
                        todo={todo}
                        onToggle={toggleTodo}
                        onDelete={deleteTodo}
                    />
                ))}
            </ul>
            <TodoStats todos={todos} />
        </div>
    );
}

שימו לב:
- סטייט מינימלי: todos ו-filter בלבד. filteredTodos הוא חישוב, לא סטייט
- סטייט באב: TodoApp מחזיק את הסטייט כי כל הקומפוננטות צריכות אותו
- זרימה הפוכה: AddTodoForm קורא ל-onAdd, TodoItem קורא ל-onToggle ו-onDelete
- סטייט מקומי: text של AddTodoForm הוא סטייט מקומי - רק הטופס צריך אותו

סיכום

  • חשיבה בריאקט מתחילה בפירוק ה-UI לקומפוננטות לפי אחריות בודדת
  • גרסה סטטית קודם - בלי סטייט, רק props
  • סטייט מינימלי - שמרו רק מה שלא ניתן לחשב
  • סטייט חי באב המשותף הקרוב ביותר של הקומפוננטות שצריכות אותו
  • זרימה הפוכה - העברת callbacks כ-props כדי שילדים ישנו את סטייט האב
  • דפוסי הרכבה: children, slots, custom hooks
  • כללי אצבע: התחילו פשוט, פצלו כשצריך, העדיפו חישוב על סטייט