Component Composition ב-React: איך לבנות קומפוננטות גמישות ושימושיות

סער טויטו

סער טויטו

Software & Web Development


הנה שאלת ראיון עבודה ב-React (אבל האמת, זה רלוונטי כמעט לכל פרויקט פיתוח): איך הייתם בונים כרטיס (Card) שיכול להופיע במיליון וריאציות שונות - עם תמונה או בלי, עם כפתור לייק או בלי, עם לינק או בלי… מבלי לשכפל קוד בכל פעם מחדש?

Different card variations showing composition pattern

התשובה היא 👈 Component Composition.

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

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

מה זה Component Composition?

Component Composition היא תבנית עיצוב שמאפשרת לבנות קומפוננטות מורכבות על ידי שילוב של קומפוננטות קטנות ופשוטות יותר. במקום ליצור הקומפוננטה מונוליתית גדולה עם הרבה פונקציונליות, אנחנו מפרקים את הקומפוננטה לקומפוננטות קטנות, שכל אחת מהן אחראית על משימה ספציפית.

בתמונה למעלה אתם יכולים לראות בדיוק את מה שאנחנו הולכים לבנות - אותם building blocks שיוצרים 4 סוגים שונים של כרטיסים:

  • Basic Card - כרטיס פשוט עם תוכן
  • Interactive Card - כרטיס עם כפתור לייק
  • Linked Card - כרטיס שכל הקליק עליו מוביל לקישור
  • Full Composition - כרטיס עם תמונה, overlay, וכל הפונקציונליות

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

components/
├── Card/
│   ├── CardContent/
│   │   └── CardContent.tsx
│   ├── ImageOverlay/
│   │   └── ImageOverlay.tsx
│   ├── LikeButton/
│   │   └── LikeButton.tsx
│   ├── LinkWrapper/
│   │   └── LinkWrapper.tsx
│   ├── Card.tsx

בניית הקומפוננטות הבסיסיות

נתחיל עם הקומפוננטות הבסיסיות ביותר - Card ו-CardContent. אלה הם building blocks שעליהם נבנה את שאר הפונקציונליות. הקומפוננטות האלה יהיו פשוטים מאוד - הם רק יספקו מבנה ועיצוב בסיסי.

ראשית, בואו ניצור את הקומפוננטה Card הבסיסית:

// Card Component - Basic wrapper
import React from 'react';
import styles from './Card.module.scss';

interface CardProps {
  children: React.ReactNode;
  className?: string;
}

export const Card: React.FC<CardProps> = ({ children, className = "" }) => {
  return (
    <div className={`${styles.card} ${className}`}>
      {children}
    </div>
  );
};

הקומפוננטה הזו פשוטה מאוד - היא מקבלת children ו-class אופציונלי, ופשוט עוטפת אותם ב-div עם styling בסיסי.

עכשיו נוסיף תת-קומפוננטה שאחראית על התוכן של הכרטיס:

// CardContent Component - Content container
import React from 'react';
import styles from './CardContent.module.scss';

interface CardContentProps {
  children: React.ReactNode;
  className?: string;
}

export const CardContent: React.FC<CardContentProps> = ({ children, className = "" }) => {
  return (
    <div className={`${styles.content} ${className}`}>
      {children}
    </div>
  );
};

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

הוספת פונקציונליות - LikeButton

עכשיו נוסיף קומפוננטה נוספת עם פונקציונליות ספציפית - כפתור לייק. זה יהיה הקומפוננטה הראשונה שלנו שיש לה state פנימי ולוגיקה עסקית. היא תהיה אחראית רק על פונקציונליות הלייק ושום דבר אחר.

import React, { useState } from 'react';
import { Heart } from 'lucide-react';
import styles from './LikeButton.module.scss';

interface LikeButtonProps {
  initialLiked?: boolean;
  onLike?: (liked: boolean) => void;
  className?: string;
}

export const LikeButton: React.FC<LikeButtonProps> = ({
  initialLiked = false,
  onLike,
  className = ""
}) => {
  const [liked, setLiked] = useState(initialLiked);

  const handleClick = () => {
    const newLiked = !liked;
    setLiked(newLiked);
    onLike?.(newLiked); // Call callback if provided
  };

  return (
    <button
      onClick={handleClick}
      className={`${styles.button} ${liked ? styles.liked : styles.notLiked} ${className}`}
    >
      <Heart size={16} className={liked ? styles.heartFilled : styles.heart} />
      {liked ? 'Liked' : 'Like'}
    </button>
  );
};

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

תמונה עם Overlay - ImageOverlay

בואו נוסיף קומפוננטה נוספת שנצטרך - ImageOverlay. זו הקומפוננטה שמציגה תמונה עם יכולת להוסיף תוכן נוסף מעליה (כמו "Featured" בדוגמה שלנו):

import React from 'react';
import styles from './ImageOverlay.module.scss';

interface ImageOverlayProps {
  src: string;
  alt: string;
  overlay?: React.ReactNode;
  className?: string;
}

export const ImageOverlay: React.FC<ImageOverlayProps> = ({
  src,
  alt,
  overlay,
  className = ""
}) => {
  return (
    <div className={`${styles.imageContainer} ${className}`}>
      <img src={src} alt={alt} className={styles.image} />
      {overlay && (
        <div className={styles.overlay}>
          {overlay}
        </div>
      )}
    </div>
  );
};

שוב, הקומפוננטה פשוטה וממוקדת - היא אחראית רק על הצגת תמונה עם תוכן אופציונלי מעליה. אם אין overlay, היא פשוט לא מציגה אותו.

מעטף לקישורים - LinkWrapper

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

import React from 'react';
import { ExternalLink } from 'lucide-react';
import styles from './LinkWrapper.module.scss';

interface LinkWrapperProps {
  href?: string;
  children: React.ReactNode;
  external?: boolean;
  className?: string;
}

export const LinkWrapper: React.FC<LinkWrapperProps> = ({
  href,
  children,
  external = false,
  className = ""
}) => {
  // If no href provided, just return children as-is
  if (!href) {
    return <>{children}</>;
  }

  // Configure link props based on external flag
  const linkProps = external
    ? { target: "_blank", rel: "noopener noreferrer" }
    : {};

  return (
    <a href={href} className={`${styles.link} ${className}`} {...linkProps}>
      {children}
      {external && <ExternalLink size={14} className={styles.externalIcon} />}
    </a>
  );
};

זו דוגמה מעולה ל-conditional rendering ולעיקרון הגמישות. הקומפוננטה הזו מאפשרת לנו להוסיף או להסיר פונקציונליות קישור מכל תוכן בלי לשנות את הקומפוננטה המקורית.

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

עכשיו הגיע החלק המהנה - נראה איך לשלב את הקומפוננטות האלו יחד בדרכים שונות. זה בדיוק מה שראיתם בתמונה בתחילת המאמר - אותם building blocks יוצרים 4 חוויות שונות לגמרי!

1. Basic Card - הבסיס הפשוט

זהו הכרטיס הפשוט ביותר - רק תוכן בסיסי:

// Basic card - just content
<Card>
  <CardContent>
    <h3>Basic Card</h3>
    <p>Simple card with content</p>
  </CardContent>
</Card>

2. Interactive Card - הוספת אינטרקטיביות

עכשיו נוסיף כפתור לייק לאותו כרטיס:

// Interactive card with like functionality
<Card>
  <CardContent>
    <h3>Interactive Card</h3>
    <p>Card with like button</p>
    <LikeButton onLike={(liked) => console.log('Liked:', liked)} />
  </CardContent>
</Card>

3. Linked Card - כל הכרטיס כקישור

עכשיו נהפוך את כל הכרטיס לקליק עם אייקון קישור חיצוני:

// Clickable card - entire card becomes a link
<LinkWrapper href="https://example.com" external>
  <Card>
    <CardContent>
      <h3>Linked Card</h3>
      <p>Click anywhere to navigate</p>
    </CardContent>
  </Card>
  </LinkWrapper>

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

שילוב מורכב - Full Composition

ועכשיו הדוגמה הכי מרשימה - הכרטיס המורכב ביותר שמשלב את כל מה שבנינו: תמונה עם overlay, קישור, וכפתור לייק. זו בדיוק הדוגמה הרביעית בתמונה:

// Complex composition - Image + Link + Interactive elements
<LinkWrapper href="https://example.com" external>
  <Card>
    <ImageOverlay
      src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=200&fit=crop"
      alt="Mountain landscape"
      overlay={<div style={{ color: 'white', fontWeight: 'bold' }}>Featured</div>}
    />
    <CardContent>
      <h3>Full Composition</h3>
      <p>Image + Link + Like button</p>
      {/* Prevent like button from triggering link */}
      <div onClick={(e) => e.preventDefault()}>
        <LikeButton initialLiked />
      </div>
    </CardContent>
  </Card>
  </LinkWrapper>

הפרט החשוב כאן היא שעטפנו את ה-LikeButton ב-div עם preventDefault כדי למנוע מהקליק על הלייק לפעיל גם את הקישור. זה מדגים איך composition מאפשר לנו לפתור בעיות מורכבות של UX בצורה נקייה ומובנת.

Compound Components - דפוס מתקדם יותר

עד כה ראינו Component Composition - קומפוננטות עצמאיות שניתן לשלב. עכשיו בואו נכיר דפוס מתקדם יותר: Compound Components. זהו דפוס ספציפי בתוך עולם ה-composition שבו קומפוננטות עובדות יחד כיחידה אחת ומשתפות state או לוגיקה.

ב-Compound Components יש קומפוננטת-אב אחת והקומפוננטות ילדים "מדברים" איתה דרך Context API. בואו נראה דוגמה של Select עם Options:

// Compound Components example - Select with Options
import React, { createContext, useContext, useState } from 'react';

// Create context for internal communication
const SelectContext = createContext();

// Parent component that manages state
function Select({ children, defaultValue, onChange }) {
  const [value, setValue] = useState(defaultValue);
  
  const handleValueChange = (newValue) => {
    setValue(newValue);
    onChange?.(newValue);
  };

  return (
    <SelectContext.Provider value={{ value, setValue: handleValueChange }}>
      <div className="select">{children}</div>
    </SelectContext.Provider>
  );
}

// Child component that communicates with parent through context
function Option({ value, children }) {
  const { value: selectedValue, setValue } = useContext(SelectContext);
  const isSelected = selectedValue === value;

  return (
    <div
      className={`option ${isSelected ? 'selected' : ''}`}
      onClick={() => setValue(value)}
    >
      {children}
    </div>
  );
}

// Attach Option as static property for cleaner API
Select.Option = Option;

export { Select };

וכך נשתמש בקומפוננטות האלה:

// Usage - Notice how clean the API is
<Select defaultValue="1" onChange={(value) => console.log('Selected:', value)}>
  <Select.Option value="1">אופציה 1</Select.Option>
  <Select.Option value="2">אופציה 2</Select.Option>
  <Select.Option value="3">אופציה 3</Select.Option>
</Select>

כאן ה-Option לא עובד לבד - הוא חייב להיות בתוך <Select>. כל הילדים "מדברים" עם האב דרך context. זה מה שהופך את זה ל-compound components.

סיכום

אז איך עונים על שאלת הראיון שפתחנו איתה? בדיוק כמו שראיתם בתמונה בתחילת המאמר - Component Composition מאפשר לבנות ממשקי משתמש גמישים ושימושיים. במקום קומפוננטה אחד עם עשרות props, בנינו חלקים קטנים וגמישים שניתן לשלב בדרכים בלתי מוגבלות.

חזרו לתמונה למעלה ותראו איך 4 building blocks פשוטים יצרו 4 חוויות שונות לגמרי - וזה רק ההתחלה! אפשר לשלב את הקומפוננטות האלה עם עוד קומפוננטות ליצירת עשרות וריאציות נוספות.