|
| 1 | +--- |
| 2 | +ruleId: CLEAN-REACT-PATTERNS-1 |
| 3 | +title: Favor composition over configuration |
| 4 | +--- |
| 5 | + |
| 6 | +## [CLEAN-REACT-PATTERNS-1] Favor composition over configuration |
| 7 | + |
| 8 | +### Reasoning |
| 9 | + |
| 10 | +When new features are implemented by adding configuration (props, flags, conditional logic) to existing components, if requirements change, then those components must be repeatedly modified, increasing coupling, surface area, and regression risk. Composition ensures features scale horizontally, limits the scope of changes, and prevents components from becoming configuration-driven "mega components". |
| 11 | + |
| 12 | +### Incorrect |
| 13 | + |
| 14 | +#### Incorrect (configuration) |
| 15 | + |
| 16 | +- Features controlled by boolean flags |
| 17 | +- Adding a new feature requires modifying the Table component's API |
| 18 | + |
| 19 | +```tsx |
| 20 | +<Table |
| 21 | + data={items} |
| 22 | + columns={columns} |
| 23 | + shouldShowSearchBar |
| 24 | + shouldShowHeader |
| 25 | + shouldEnableSorting |
| 26 | + shouldShowPagination |
| 27 | + shouldHighlightOnHover |
| 28 | +/> |
| 29 | + |
| 30 | +type TableProps = { |
| 31 | + data: Item[]; |
| 32 | + columns: Column[]; |
| 33 | + shouldShowSearchBar?: boolean; // Could be <Table.SearchBar /> |
| 34 | + shouldShowHeader?: boolean; // Could be <Table.Header /> |
| 35 | + shouldEnableSorting?: boolean; // Configuration for header behavior |
| 36 | + shouldShowPagination?: boolean; // Could be <Table.Pagination /> |
| 37 | + shouldHighlightOnHover?: boolean; // Configuration for styling behavior |
| 38 | +}; |
| 39 | +``` |
| 40 | + |
| 41 | +```tsx |
| 42 | +<SelectionList |
| 43 | + data={items} |
| 44 | + shouldShowTextInput |
| 45 | + shouldShowTooltips |
| 46 | + shouldScrollToFocusedIndex |
| 47 | + shouldDebounceScrolling |
| 48 | + shouldUpdateFocusedIndex |
| 49 | + canSelectMultiple |
| 50 | + disableKeyboardShortcuts |
| 51 | +/> |
| 52 | + |
| 53 | +type SelectionListProps = { |
| 54 | + shouldShowTextInput?: boolean; // Could be <SelectionList.TextInput /> |
| 55 | + shouldShowConfirmButton?: boolean; // Could be <SelectionList.ConfirmButton /> |
| 56 | + textInputOptions?: {...}; // Configuration object for the above |
| 57 | +}; |
| 58 | +``` |
| 59 | + |
| 60 | +#### Incorrect (parent manages child state) |
| 61 | + |
| 62 | +```tsx |
| 63 | +// Parent fetches and manages state for its children |
| 64 | +// Parent has to know child implementation details |
| 65 | +function ReportScreen({ params: { reportID }}) { |
| 66 | + const [reportOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {allowStaleData: true, canBeMissing: true}); |
| 67 | + const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]); |
| 68 | + const [reportMetadata = defaultReportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDFromRoute}`, {canBeMissing: true, allowStaleData: true}); |
| 69 | + const {reportActions: unfilteredReportActions, linkedAction, sortedAllReportActions, hasNewerActions, hasOlderActions} = usePaginatedReportActions(reportID, reportActionIDFromRoute); |
| 70 | + const parentReportAction = useParentReportAction(reportOnyx); |
| 71 | + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); |
| 72 | + const isTransactionThreadView = isReportTransactionThread(report); |
| 73 | + // other onyx connections etc |
| 74 | + |
| 75 | + return ( |
| 76 | + <> |
| 77 | + <ReportActionsView |
| 78 | + report={report} |
| 79 | + reportActions={reportActions} |
| 80 | + isLoadingInitialReportActions={reportMetadata?.isLoadingInitialReportActions} |
| 81 | + hasNewerActions={hasNewerActions} |
| 82 | + hasOlderActions={hasOlderActions} |
| 83 | + parentReportAction={parentReportAction} |
| 84 | + transactionThreadReportID={transactionThreadReportID} |
| 85 | + isReportTransactionThread={isTransactionThreadView} |
| 86 | + /> |
| 87 | + // other features |
| 88 | + <Composer /> |
| 89 | + </> |
| 90 | + ); |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +### Correct |
| 95 | + |
| 96 | +#### Correct (composition) |
| 97 | + |
| 98 | +- Features expressed as composable children |
| 99 | +- Parent stays stable; add features by adding children |
| 100 | + |
| 101 | +```tsx |
| 102 | +<Table data={items} columns={columns}> |
| 103 | + <Table.SearchBar /> |
| 104 | + <Table.Header /> |
| 105 | + <Table.Body /> |
| 106 | +</Table> |
| 107 | +``` |
| 108 | + |
| 109 | +```tsx |
| 110 | +<SelectionList data={items}> |
| 111 | + <SelectionList.TextInput /> |
| 112 | + <SelectionList.Body /> |
| 113 | +</SelectionList> |
| 114 | +``` |
| 115 | + |
| 116 | +#### Correct (children manage their own state) |
| 117 | + |
| 118 | +```tsx |
| 119 | +// Children are self-contained and manage their own state |
| 120 | +// Parent only passes minimal data (IDs) |
| 121 | +// Adding new features doesn't require changing the parent |
| 122 | +function ReportScreen({ params: { reportID }}) { |
| 123 | + return ( |
| 124 | + <> |
| 125 | + <ReportActionsView reportID={reportID} /> |
| 126 | + // other features |
| 127 | + <Composer /> |
| 128 | + </> |
| 129 | + ); |
| 130 | +} |
| 131 | + |
| 132 | +// Component accesses stores and calculates its own state |
| 133 | +// Parent doesn't know the internals |
| 134 | +function ReportActionsView({ reportID }) { |
| 135 | + const [reportOnyx] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); |
| 136 | + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); |
| 137 | + // ... |
| 138 | +} |
| 139 | +``` |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +### Review Metadata |
| 144 | + |
| 145 | +#### Condition |
| 146 | + |
| 147 | +Flag ONLY when ALL of these are true: |
| 148 | + |
| 149 | +- Any of these scenarios apply: |
| 150 | + - A **new feature** is being introduced |
| 151 | + - An **existing component's API** is being expanded with new props |
| 152 | + - A **refactoring** creates a new component that still has boolean configuration props matching the search patterns controlling branching logic — refactoring is an opportunity to eliminate configuration flags, not preserve them |
| 153 | +- The component contains boolean props matching the search patterns that cause `if/else` or ternary branching inside the component body |
| 154 | +- These configuration options control feature presence, layout strategy, or behavior within the component |
| 155 | + |
| 156 | +**Features that should NOT be controlled by boolean flags:** |
| 157 | +- Optional UI elements that could be composed in |
| 158 | +- New behavior that could be introduced as new children |
| 159 | +- Features that currently require parent component code changes |
| 160 | +- Layout strategy variants |
| 161 | + |
| 162 | +**DO NOT flag if:** |
| 163 | +- Props are non-boolean data values needed for coordination between composed parts (e.g., `reportID`, `data`, `columns`). |
| 164 | +- The component uses composition and child components for features |
| 165 | +- Parent components stay stable as features are added |
| 166 | + |
| 167 | +**Search Patterns** (hints for reviewers): |
| 168 | +- `should\w+` (any prop starting with `should`) |
| 169 | +- `canSelect` |
| 170 | +- `enable` |
| 171 | +- `disable` |
0 commit comments