Configuration-Driven React Components: One Component, Many Use Cases
You build a data table for Users. It works great. Then you need one for Products. Copy-paste, tweak a few things. Then Orders. Then Invoices. Then Agents.
Now you have five components that started identical but drifted. Bug fix in one? Manual port to four others. New feature? Five implementations.
The obvious fix: abstract into one reusable component. But now you have <DataTable> with 47 boolean props. showPagination, allowSort, allowFilter, enableExport, showCheckboxes… Adding a feature means adding a prop. Testing means combinatorial explosion.
There’s a middle ground: configuration-driven components. One prop. One config object. All variation lives in the config, not the component.
+-----------------------------------------------------------+
| THE PROP EXPLOSION TRAP |
| |
| <DataTable |
| data={users} |
| columns={cols} |
| showPagination={true} |
| allowSort={true} |
| allowFilter={false} |
| enableExport={true} <- 47 props and growing |
| onRowClick={...} |
| ... |
| /> |
+-----------------------------------------------------------+
+-----------------------------------------------------------+
| THE CONFIG SOLUTION |
| |
| <InventoryTab config={usersConfig} /> |
| |
| One prop. All variation in the config object. |
+-----------------------------------------------------------+
The component stays thin. Configs are just data—easy to test, easy to diff, easy to generate.
The Core Idea
Instead of:
<DataTable
data={users}
columns={cols}
showPagination
allowSort
onRowClick={...}
exportFileName="users.csv"
/* 20 more props */
/>
Do:
<InventoryTab config={usersConfig} />
All variation lives in usersConfig. The component stays thin.
The Config Interface
interface TabConfig<TData> {
key: string;
label: string;
createColumns: (ctx: ColumnContext) => ColumnDef<TData>[];
dataSource: DataSource<TData>;
defaultHiddenColumns?: Record<string, boolean>;
exportColumns?: string[];
}
interface ColumnContext {
navigate: (path: string) => void;
onItemClick: (item: unknown) => void;
}
interface DataSource<TData> {
queryKey: string[];
fetchData: () => Promise<{ data: TData[] }>;
}
Columns as Factory Functions
Static arrays can’t access runtime context:
// Bad: static array
const columns = [
{ accessorKey: 'name', header: 'Name' },
];
// Good: factory function
const createColumns = (ctx: ColumnContext) => [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<button onClick={() => ctx.onItemClick(row.original)}>
{row.original.name}
</button>
),
},
];
The factory receives context at render time. It can use navigation, open sheets, trigger actions.
Data Source Contract
const usersDataSource: DataSource<User> = {
queryKey: ["users"],
fetchData: async () => {
const res = await api.post("/users/query", {});
return { data: res.data };
},
};
Same interface, different implementations. Component doesn’t care where data comes from.
The Generic Component
function InventoryTab<T>({ config }: { config: TabConfig<T> }) {
const [selected, setSelected] = useState<T | null>(null);
const columns = useMemo(
() =>
config.createColumns({
navigate: useNavigate(),
onItemClick: setSelected,
}),
[config]
);
const { data } = useQuery({
queryKey: config.dataSource.queryKey,
queryFn: config.dataSource.fetchData,
});
return (
<>
<DataTable
columns={columns}
data={data?.data ?? []}
columnVisibility={config.defaultHiddenColumns}
/>
{selected && (
<DetailSheet item={selected} onClose={() => setSelected(null)} />
)}
</>
);
}
~30 lines. Handles any entity type.
Adding a New Entity
Three steps:
1. Define type
interface Product {
id: string;
name: string;
price: number;
}
2. Create config
const productsConfig: TabConfig<Product> = {
key: 'products',
label: 'Products',
createColumns: ({ onItemClick }) => [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<button onClick={() => onItemClick(row.original)}>
{row.original.name}
</button>
),
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) => `$${row.original.price}`,
},
],
dataSource: productsDataSource,
exportColumns: ['name', 'price'],
};
3. Use it
<InventoryTab config={productsConfig} />
Done. Full data table with click handling, export columns, everything.
When NOT to Use This
- <3 similar use cases: Don’t abstract yet
- Highly visual/custom UI: Config can’t capture visual complexity
- Variations are behavioral: Use composition instead
| Props | Config |
|---|---|
| <5 variations | 5+ variations |
| Boolean toggles | Structural differences |
| Rare changes | Frequent changes |
Testing
Configs are easier to test than prop combinations:
test("productsConfig creates correct columns", () => {
const ctx = { navigate: vi.fn(), onItemClick: vi.fn() };
const columns = productsConfig.createColumns(ctx);
expect(columns.map(c => c.accessorKey)).toEqual(["name", "price"]);
});
One test per config. No combinatorial explosion.
Results
Eight entity types:
- New entities in <30 minutes
- Bug fixes propagate everywhere
- Consistent UX across all tables
- Code reviews faster (is this config correct? vs. is this 500-line component correct?)
Configs feel like more code. They’re less complexity.
Red Flags
- Config interface growing past 10 fields → Split into sub-configs
createColumnsreturning wildly different structures → Need different components- Too many optional fields → Probably need separate types
Next
Configs live in code. What if they came from an API? API-Driven UI Configuration.
Stop adding props. Define configurations.