Skip to content

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
PropsConfig
<5 variations5+ variations
Boolean togglesStructural differences
Rare changesFrequent 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
  • createColumns returning 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.