← Back to list

Inversion of Control ?

Introduction

Muyiwa Johnson · 2025-12-03 05:37 · 16 claps · 3.5 min read
#software-architecture #react-best-practices #frontend-engineering #design-patterns #code-maintainability
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🏛️ · Architecture

Inversion of Control ?

Introduction

If you have ever written a lot of code, you already know this truth:

Sometimes, we give way too much control to our components.

Not because we’re bad developers. Not because we don’t understand abstraction. But simply because, in an attempt to make things “reusable,” we accidentally create components that seize behavior instead of hosting it.

Most systems break not because the logic is wrong, but because the control is in the wrong place.

Let’s look at a painfully familiar example.

<DataTable
  fetchUrl="/api/users"
  enableSearch
  enableFiltering
  enableSorting
  enablePagination
  enableCSVExport
  filters={[...]}
  defaultSort="name"
  onRowSelect={() => {}}
  onRowExpand={() => {}}
  pageSize={20}
  debounceMs={300}
  retryOnFail
  rowVariant="striped"
  selectableRows
  expandableRows
  customCellRenderer={fn}
  errorRenderer={fn}
  emptyRenderer={fn}
  loadingRenderer={fn}
  // ...plus 20 more options
/>

You’ve seen this happen:

  • functions that decide how to fetch
  • classes that create their own dependencies
  • components that choose API endpoints
  • utilities that hard-code logic
  • widgets that “think” they own the business rules

Every time a module takes ownership of behavior that should be supplied from outside, it grows tighter, heavier, and harder to reuse.

The above example is not a DataTable problem, it is a control problem. This is where Inversion of control (IoC) comes to shine.

what is inversion of control

Inversion of Control (IoC) is not a pattern. It’s not a library. It’s not a framework trick.

It is a design philosophy:

The provider controls the lifecycle; the consumer controls the behavior.

Instead of components deciding the rules, they host the rules supplied by their parent. lets think of it this way

Without IoC

The DataTable decides:

  • how to fetch
  • how to sort
  • how to filter
  • how to transform rows
  • how to debounce
  • how to paginate
  • when to run all these

It owns the entire flow.

Parent ----> DataTable ----> fetch
                          ----> sort
                          ----> filter
                          ----> debounce
                          ----> pagination
                          ----> render logic
                          ----> error logic
                          ----> row transformation
                          ----> everything else ....

The component owns ALL the logic. see example below

// BLOATED DATATABLE (TOO MUCH CONTROL)

export function DataTable(props) {
  const {
    fetchUrl,
    enableSearch,
    enableFiltering,
    enableSorting,
    enablePagination,
    filters,
    defaultSort,
    pageSize,
    debounceMs,
    customCellRenderer,
    errorRenderer,
    emptyRenderer,
    loadingRenderer,
  } = props;

  const [rows, setRows] = useState([]);
  const [search, setSearch] = useState("");
  const [sort, setSort] = useState(defaultSort);
  const [page, setPage] = useState(1);

  // It fetches by itself
  useEffect(() => {
    fetch(fetchUrl)
      .then(r => r.json())
      .then(data => {
        let result = data;

        // It sorts by itself
        if (enableSorting) {
          result = sortRows(result, sort);
        }

        // It filters by itself
        if (enableFiltering) {
          result = filterRows(result, filters);
        }

        // It searches by itself
        if (enableSearch) {
          result = searchRows(result, search);
        }

        // It paginates by itself
        if (enablePagination) {
          result = paginate(result, page, pageSize);
        }

        setRows(result);
      });
  }, [fetchUrl, search, sort, page, filters]);

  return (
    <table>
      {/* ... */}
    </table>
  );
}

With IoC

The DataTable decides only:

  • when sorting should trigger
  • when filtering should trigger
  • when pagination should advance
  • when rows should be rendered

But the user injects the behaviors:

  • fetch logic
  • sorting logic
  • filtering logic
  • transformation logic
  • cell rendering logic

The behavior is injected. The lifecycle is controlled.

Parent ---- injects behaviors ----> fetch()
                                    sort()
                                    filter()
                                    render()

DataTable ---- controls ----> lifecycle
                              when sorting triggers
                              when filtering triggers
                              when pagination happens
                              when rows re-render

Behavior flows downward from the parent. Lifecycle flows upward from the component.

Let’s rewrite the DataTable using IoC principles. We remove all behavior from the table. We keep only lifecycle + rendering.

// IOC TABLE (LEAN, FLEXIBLE, BEAUTIFUL)

type TableProps<T> = {
  rows: T[];
  sort: SortState;
  onSortChange: (s: SortState) => void;
  filters: FilterState;
  onFilterChange: (f: FilterState) => void;
  children: React.ReactNode;
};

export function Table<T>({
  rows,
  sort,
  onSortChange,
  filters,
  onFilterChange,
  children,
}: TableProps<T>) {
  return (
    <>
      {/* Sorting UI owned by table */}
      <SortControls value={sort} onChange={onSortChange} />

      {/* Filter UI owned by table */}
      <FilterControls value={filters} onChange={onFilterChange} />

      <table>
        {children}
        <tbody>
          {rows.map((r, i) => (
            <tr key={i}>{/* table only renders */}</tr>
          ))}
        </tbody>
      </table>
    </>
  );
}

The table no longer: fetches, filters, sorts, paginates, transforms, searches, debounces.

It simply orchestrates the lifecycle:

  • “Sorting changed — parent, do something.”
  • “Filtering changed — parent, do something.”
  • “Render these rows.”

Everything else lives in the parent.

Parent Now Controls Behavior

function UsersPage() {
  const [sort, setSort] = useState({ column: "name", direction: "asc" });
  const [filters, setFilters] = useState({ status: "active" });

  const rows = useUserQuery({ sort, filters }); // ← consumer decides behavior

  return (
    <Table
      rows={rows}
      sort={sort}
      onSortChange={setSort}
      filters={filters}
      onFilterChange={setFilters}
    >
      <Table.Column title="Name" render={(user) => user.name} />
      <Table.Column title="Email" render={(user) => user.email} />
    </Table>
  );
}

Now the table doesn’t care if rows come from:

  • REST
  • GraphQL
  • tRPC
  • localStorage
  • IndexedDB
  • mock data
  • a cache
  • server components
  • a streaming API

The behavior is injected. The lifecycle is owned.

This is Inversion of Control.

Conclusion

At the end of the day, this was never about building the “perfect” DataTable. It was about control and why giving components too much of it always backfires.

Once I started using Inversion of Control, my components instantly became lighter, easier to reuse, and way less stressful to maintain. The magic is simple:

Let the component handle when, and let you decide how.

That’s the shift.Stop letting components own your logic. Start letting them host it. Your future self and your entire codebase will thank you.


메타데이터
post_id
ae48369bb9e2
slug
inversion-of-control-ae48369bb9e2
url
https://medium.com/@muyiwamighty/inversion-of-control-ae48369bb9e2
canonical_url
https://medium.com/@muyiwamighty/inversion-of-control-ae48369bb9e2
author_url
https://medium.com/@muyiwamighty
status
ok
fetched_at
2026-08-25 12:53:18