The Most Advanced React Book Buy Now

React Enterprise Component Patterns - Inversion of Control and JSX Injection via Context API

undefined article image

As components and functionality grow, maintaining and extending a component's functionality becomes much more complex. This is especially the case for large components that utilise other components to provide specific features. In this article, we will cover how to extend the functionality of a deeply nested component by taking advantage of Inversion of Control (IoC) and utilising the Context API to inject JSX.


The code for this tutorial can be found in this GitHub repository. Below you can find an interactive Stackblitz example.

What's Inversion of Control (IoC)?

Inversion of Control (IoC) is a principle in software design that makes it easier to decouple things by inverting the control flow of a program. I know this sentence will probably mean nothing to most people and leaves them scratching their heads. Basically, when applied to React, IoC is a way to build components that are flexible and easy to customise. Let's not focus too much on theory; instead let's do some coding and it should be clearer what IoC is about.

Contrived Example Setup

We will set up a simple, contrived code example to demonstrate how to implement components using the IoC principle and inject JSX using Context API. Let's say we have a generic Table component that renders a list of rows and columns. Each row has an actions column that contains a menu button that opens a dropdown menu with a few actions, such as View, Edit, and Delete. The rendering flow is as follows:

  • The Table component loops through the rows and renders the TableRow component.
  • The TableRow component loops through columns and renders the TableColumn component.
  • The TableColumn component renders column text or TableColumnActions component if the current column is Actions.

The image below shows what it looks like.

Table example

Let's create a simple Table component for the purpose of this tutorial. I've seen implementations like this one a few times in the past, so it should be suitable for this example. If you would like to follow along, you can create a new React project using Vite.

First, we have a file with table options.

src/tableOptions.js

export const tableOptions = {
  headers: ['ID', 'Name', 'Last Name', 'Actions'],
  rows: [
    {
      id: '1',
      columns: [
        {
          column: 'ID',
          text: '1',
        },
        {
          column: 'Name',
          text: 'John',
        },
        {
          column: 'Last Name',
          text: 'John',
        },
        {
          column: 'Actions',
          text: '',
        },
      ],
    },
    {
      id: '2',
      columns: [
        {
          column: 'ID',
          text: '2',
        },
        {
          column: 'Name',
          text: 'Melissa',
        },
        {
          column: 'Last Name',
          text: 'Lark',
        },
        {
          column: 'Actions',
          text: '',
        },
      ],
    },
    {
      id: '3',
      columns: [
        {
          column: 'ID',
          text: '3',
        },
        {
          column: 'Name',
          text: 'Mark',
        },
        {
          column: 'Last Name',
          text: 'Smith',
        },
        {
          column: 'Actions',
          text: '',
        },
      ],
    },
  ],
}

As you can see, the table will have four columns and three rows.

src/App.jsx

import './App.css'
import { tableOptions } from './tableOptions'
import Table from './components/table/Table'

function App() {
  const onViewData = column => {
    alert(`On View Column ${column.column}`)
  }
  const onEditData = column => {
    alert(`On Edit Column ${column.column}`)
  }
  const onDeleteData = column => {
    alert(`On Delete Column ${column.column}`)
  }

  return (
    <div className='container mx-auto py-8'>
      <h1 className='text-center font-semibold text-lg'>
        React Enterprise Component Patterns - JSX Injection via Context API
      </h1>
      <main className='mt-8 flex justify-center'>
        <Table
          options={tableOptions}
          onViewData={onViewData}
          onEditData={onEditData}
          onDeleteData={onDeleteData}
        />
      </main>
    </div>
  )
}

export default App

The App component renders the Table component that receives 4 props:

  • options - The configuration for rows and columns.
  • onViewData - The function that is executed when the View menu option is clicked.
  • onEditData - The function that is executed when the Edit menu option is clicked.
  • onDeleteData - The function that is executed when the Delete menu option is clicked.

Next, let's create components for the table.

src/components/table/Table.jsx

import TableRow from './components/TableRow'

const Table = props => {
  const { options, onViewData, onEditData, onDeleteData } = props
  const { headers, rows } = options
  return (
    <table>
      <thead>
        <TableRow header columns={headers} />
      </thead>
      <tbody>
        {rows.map(row => {
          return (
            <TableRow
              columns={row.columns}
              key={row.id}
              onViewData={onViewData}
              onEditData={onEditData}
              onDeleteData={onDeleteData}
            />
          )
        })}
      </tbody>
    </table>
  )
}

export default Table

The Table component utilises the TableRow component for table headers and the rows array.

src/components/table/components/TableRow.jsx

import TableHeader from './TableHeader'
import TableColumn from './TableColumn'

const TableRow = props => {
  const { columns, header, onViewData, onEditData, onDeleteData } = props
  const CellComponent = header ? TableHeader : TableColumn
  return (
    <tr className='even:bg-gray-50 hover:bg-blue-50'>
      {columns.map((column, idx) => {
        return (
          <CellComponent
            key={idx}
            column={column}
            onViewData={onViewData}
            onEditData={onEditData}
            onDeleteData={onDeleteData}
          />
        )
      })}
    </tr>
  )
}

export default TableRow

The TableRow component loops through the columns array and renders the CellComponent element, which can be either the TableHeader or TableColumn.

src/components/table/components/TableHeader.jsx

const TableHeader = props => {
  return <th className='px-4 py-1 bg-indigo-50'>{props.column}</th>
}

export default TableHeader

The TableHeader component just renders the th element with some styles.

src/components/table/components/TableColumn.jsx

import TableColumnActions from './TableColumnActions'

const TableColumn = props => {
  const { column, onViewData, onEditData, onDeleteData } = props

  return (
    <td className='px-4 py-1'>
      {column.column === 'Actions' ? (
        <TableColumnActions
          column={column}
          onViewData={onViewData}
          onEditData={onEditData}
          onDeleteData={onDeleteData}
        />
      ) : (
        column.text
      )}
    </td>
  )
}

export default TableColumn

The TableColumn component renders a td element with either the column text or the TableColumnActions component if the column name matches Actions. Normally, this would be done in a different way, but it's fine for our contrived example.

src/components/table/components/TableColumnActions.jsx

import { useState, useRef } from 'react'
import useOnClickOutside from 'use-onclickoutside'
import { IoEllipsisVerticalSharp } from 'react-icons/io5'
import styles from './TableColumnActions.module.css'

const TableColumnActions = props => {
  const { column, onViewData, onEditData, onDeleteData } = props
  const popupRef = useRef(null)
  const [openActions, setOpenActions] = useState(false)
  useOnClickOutside(popupRef, () => setOpenActions(false))
  return (
    <div className='relative'>
      <div className='flex justify-center'>
        <button className='cursor-pointer' onClick={() => setOpenActions(true)}>
          <IoEllipsisVerticalSharp />
        </button>
      </div>
      {openActions ? (
        <div
          className='absolute bg-white shadow border border-gray-200 z-50 min-w-[8rem]'
          ref={popupRef}
        >
          <ul className={styles.actionsMenu}>
            <li>
              <button
                className={styles.actionsMenuButton}
                onClick={() => {
                  setOpenActions(false)
                  onViewData(column)
                }}
              >
                View
              </button>
            </li>
            <li>
              <button
                className={styles.actionsMenuButton}
                onClick={() => {
                  setOpenActions(false)
                  onEditData(column)
                }}
              >
                Edit
              </button>
            </li>
            <li>
              <button
                className={styles.actionsMenuButton}
                onClick={() => {
                  setOpenActions(false)
                  onDeleteData(column)
                }}
              >
                Delete
              </button>
            </li>
          </ul>
        </div>
      ) : null}
    </div>
  )
}

export default TableColumnActions

The final component - TableColumnActions - renders a button with an icon that opens the dropdown menu with View, Edit, and Delete options. The useOnClickOutside hook is used to close the menu when a user clicks outside of it.

Below, we also have styles for the menu and its action buttons.

src/components/table/components/TableColumnActions.module.css

.actionsMenu {
  @apply py-2;
}

.actionsMenuButton {
  @apply px-4 py-1 hover:bg-gray-100 cursor-pointer w-full text-left;
}

We have the basic setup. So, now the question is, how do we change or add new options to the action menu? For instance, let's say we want to display only the View option for some items, but for others, we want to add a new option like Report? One way would be to add more props; for example, we could pass onReportData prop. However, just adding more props isn't really that flexible and scalable. We already have 3 props for View, Edit and Delete. Other ways would include configuring these as part of the options configuration or passing through a render prop. However, we still need to pass all props via a number of components that don't need them, as the props would need to be passed via Table, TableRow and TableColumn components. To avoid polluting these components, we can use Context API.

React - The Road to Enterprise cover book

React - The Road To Enterprise

Do you want to know how to create scalable and maintainable React apps with architecture that actually works?

Find out more

JSX Injection via Context API

Let's start by creating a new file called TableActionsContext.jsx. It will be responsible for creating a context for the actions menu and exporting a component that will provide it.

src/components/table/components/TableActionsContext.jsx

import { createContext, useContext } from 'react'

const TableActionsContext = createContext()

export const useTableActionsContext = () => useContext(TableActionsContext)

const TableActionsContextProvider = props => {
  const { children, renderMenu } = props
  return (
    <TableActionsContext.Provider value={renderMenu}>
      {children}
    </TableActionsContext.Provider>
  )
}

export default TableActionsContextProvider

Next, let's remove the onViewData, onEditData and onDeleteData props from table components, specifically Table, TableRow and TableColumn.

src/components/table/Table.jsx

import TableRow from './components/TableRow'

const Table = props => {
  const { options } = props
  const { headers, rows } = options
  return (
    <table>
      <thead>
        <TableRow header columns={headers} />
      </thead>
      <tbody>
        {rows.map(row => {
          return <TableRow row={row} columns={row.columns} key={row.id} />
        })}
      </tbody>
    </table>
  )
}

export default Table

src/components/table/components/TableRow.jsx

import TableHeader from './TableHeader'
import TableColumn from './TableColumn'

const TableRow = props => {
  const { row, columns, header } = props
  const CellComponent = header ? TableHeader : TableColumn
  return (
    <tr className='even:bg-gray-50 hover:bg-blue-50'>
      {columns.map((column, idx) => {
        return <CellComponent key={idx} column={column} row={row} />
      })}
    </tr>
  )
}

export default TableRow

src/components/table/components/TableColumn.jsx

import TableColumnActions from './TableColumnActions'

const TableColumn = props => {
  const { row, column } = props

  return (
    <td className='px-4 py-1'>
      {column.column === 'Actions' ? (
        <TableColumnActions row={row} column={column} />
      ) : (
        column.text
      )}
    </td>
  )
}

export default TableColumn

We also need to remove them from the TableColumnActions component and modify it, so it consumes the menu actions context.

src/components/table/components/TableColumnActions.jsx

import { useState, useRef } from 'react'
import useOnClickOutside from 'use-onclickoutside'
import { IoEllipsisVerticalSharp } from 'react-icons/io5'
import styles from './TableColumnActions.module.css'
import { useTableActionsContext } from '../TableActionsContext'

const renderViewMenuItem = (row, column, setOpenActions) => onViewData => {
  return (
    <li key='menu-item-view'>
      <button
        className={styles.actionsMenuButton}
        onClick={() => {
          setOpenActions(false)
          onViewData({ row, column })
        }}
      >
        View
      </button>
    </li>
  )
}

const renderEditMenuItem = (row, column, setOpenActions) => onEditData => {
  return (
    <li key='menu-item-edit'>
      <button
        className={styles.actionsMenuButton}
        onClick={() => {
          setOpenActions(false)
          onEditData({ row, column })
        }}
      >
        Edit
      </button>
    </li>
  )
}

const renderDeleteMenuItem = (row, column, setOpenActions) => onDeleteData => {
  return (
    <li key='menu-item-delete'>
      <button
        className={styles.actionsMenuButton}
        onClick={() => {
          setOpenActions(false)
          onDeleteData({ row, column })
        }}
      >
        Delete
      </button>
    </li>
  )
}

const renderDefaultMenu =
  (row, column, setOpenActions) =>
  ({ onViewData, onEditData, onDeleteData }) =>
    [
      renderViewMenuItem(row, column, setOpenActions)(onViewData),
      renderEditMenuItem(row, column, setOpenActions)(onEditData),
      renderDeleteMenuItem(row, column, setOpenActions)(onDeleteData),
    ]

const TableColumnActions = props => {
  const { row, column } = props
  const popupRef = useRef(null)
  const [openActions, setOpenActions] = useState(false)
  useOnClickOutside(popupRef, () => setOpenActions(false))
  const renderMenu = useTableActionsContext()

  const menuOptions = renderMenu({
    row,
    column,
    styles,
    renderViewMenuItem: renderViewMenuItem(row, column, setOpenActions),
    renderEditMenuItem: renderEditMenuItem(row, column, setOpenActions),
    renderDeleteMenuItem: renderDeleteMenuItem(row, column, setOpenActions),
    renderDefaultMenu: renderDefaultMenu(row, column, setOpenActions),
    closeMenu: () => setOpenActions(false),
  })

  return (
    <div className='relative'>
      <div className='flex justify-center'>
        <button className='cursor-pointer' onClick={() => setOpenActions(true)}>
          <IoEllipsisVerticalSharp />
        </button>
      </div>
      {openActions ? (
        <div
          className='absolute bg-white shadow border border-gray-200 z-50 min-w-[8rem]'
          ref={popupRef}
        >
          <ul className={styles.actionsMenu}>{menuOptions}</ul>
        </div>
      ) : null}
    </div>
  )
}

export default TableColumnActions

There are quite a few changes here, so let's digest them. First of all, view, edit and delete items were extracted into separate factory functions:

  • renderViewMenuItem
  • renderEditMenuItem
  • renderDeleteMenuItem

What's more, we also have an additional function called renderDefaultMenu. All four functions are passed as part of the config object to the renderMenu method, which we get access to via the useTableActionsContext method that consumes TableActionsContext.

const renderMenu = useTableActionsContext()

const menuOptions = renderMenu({
  row,
  column,
  styles,
  renderViewMenuItem: renderViewMenuItem(row, column, setOpenActions),
  renderEditMenuItem: renderEditMenuItem(row, column, setOpenActions),
  renderDeleteMenuItem: renderDeleteMenuItem(row, column, setOpenActions),
  renderDefaultMenu: renderDefaultMenu(row, column, setOpenActions),
  closeMenu: () => setOpenActions(false),
})

All these methods are passed to provide more flexibility. For example, we might want most of the rows to still display the default menu or just the view menu item. However, there could be some rows that should have additional custom items. You will see an example of that in a moment.

Besides the methods, we also pass some details, such as the row and column objects as well as styles and a method to close the actions menu.

The result of the renderMenu function is stored in the menuOptions variable and then rendered.

{
  openActions ? (
    <div
      className='absolute bg-white shadow border border-gray-200 z-50 min-w-[8rem]'
      ref={popupRef}
    >
      <ul className={styles.actionsMenu}>{menuOptions}</ul>
    </div>
  ) : null
}

Finally, let's update the App component. We need to import and utilise the TableActionsContextProvider and pass a renderMenu prop with a function that returns appropriate markup for every row item.

src/App.jsx

import React from 'react'
import './App.css'
import { tableOptions } from './tableOptions'
import Table from './components/table/Table'
import TableActionsContextProvider from './components/table/TableActionsContext'

function App() {
  const onViewData = ({ row, column }) => {
    alert(`On View Column ${column.column}`)
  }
  const onEditData = ({ row, column }) => {
    alert(`On Edit Column ${column.column}`)
  }
  const onDeleteData = ({ row, column }) => {
    alert(`On Delete Column ${column.column}`)
  }

  const reportUser = ({ row, column }) => {
    alert(`On Report User`)
  }

  const renderMenu = ({
    row,
    column,
    styles,
    closeMenu,
    renderViewMenuItem,
    renderEditMenuItem,
    renderDeleteMenuItem,
    renderDefaultMenu,
  }) => {
    switch (row.id) {
      case '1': {
        return [renderViewMenuItem(onViewData)]
      }
      case '2': {
        return [
          renderViewMenuItem(onViewData),
          renderEditMenuItem(onEditData),
          renderDeleteMenuItem(onDeleteData),
          <React.Fragment key='menu-item-report'>
            <hr className='my-2' />
            <li>
              <button
                className={styles.actionsMenuButton}
                onClick={() => {
                  closeMenu()
                  reportUser({ row, column })
                }}
              >
                Report
              </button>
            </li>
          </React.Fragment>,
        ]
      }
      default: {
        return renderDefaultMenu({
          onViewData,
          onEditData,
          onDeleteData,
        })
      }
    }
  }

  return (
    <div className='container mx-auto py-8'>
      <h1 className='text-center font-semibold text-lg'>
        React Enterprise Component Patterns - JSX Injection via Context API
      </h1>
      <main className='mt-8 flex justify-center'>
        <TableActionsContextProvider renderMenu={renderMenu}>
          <Table options={tableOptions} />
        </TableActionsContextProvider>
      </main>
    </div>
  )
}

export default App

In this example, the table has 3 rows. Based on the row id, we return different markup. The action menu for the item with ID 1 will contain only the View menu item.

case "1": {
  return [renderViewMenuItem(onViewData)];
}

On the other hand, the item with ID 2 will comprise not only View, Edit and Delete, but also a custom menu item called Report. As you can see, we use the styles passed to ensure that the custom menu item looks the same as others, and both row and column objects are passed to the reportUser function.

case "2": {
  return [
    renderViewMenuItem(onViewData),
    renderEditMenuItem(onEditData),
    renderDeleteMenuItem(onDeleteData),
    <React.Fragment key="menu-item-report">
      <hr className="my-2" />
      <li>
        <button
          className={styles.actionsMenuButton}
          onClick={() => {
            closeMenu();
            reportUser({ row, column });
          }}
        >
          Report
        </button>
      </li>
    </React.Fragment>,
  ];
}

Last but not least, any other table items will have the default menu items.

default: {
  return renderDefaultMenu({
    onViewData,
    onEditData,
    onDeleteData,
  });
}

You can see it in action in the gif below.

Table actions menu configured via renderMenu

That's the flexibility I talked about before. Initially, the TableColumnActions component was fully responsible for rendering the content, and the callbacks for menu items were provided via props. Instead, we reversed that and whilst the TableColumnActions component still has the render methods for the default items, it's flexible enough to allow one of the grandparent components to configure its markup. We can easily choose what menu items should be rendered and when. If we wanted to, we could even ignore the default ones and use custom ones only. That's basically the Inversion of Control principle in practice. By the way, passing a function and returning JSX, as we do it with the renderMenu prop, is the old render props pattern. It was used very often in the pre-hooks era, but even now, there are still use cases for it.

Summary

We have covered how to modify a component following the Inverse of Control principle to dynamically control the content of a nested component by utilising Context API to inject JSX markup. Thanks to this pattern, we were able to clean up the table components and make it easier for the consumer to dictate what menu items should be rendered for the table row by modifying the TableColumnActions to be more flexible and customisable.


Want to learn something new?

Subscribe to the newsletter!


Thomas Findlay photo

About author

Thomas Findlay is a 5 star rated mentor, full-stack developer, consultant, technical writer and the author of "The Road To Enterprise" books. He works with many different technologies such as JavaScript, Vue, Nuxt, React, Next, React Native, Node.js, Python, PHP, and more. He has obtained MSc in Advanced Computer Science degree with Distinction at Exeter University, as well as First-Class BSc in Web Design & Development at Northumbria University.

Over the years, Thomas has worked with many developers and teams from beginners to advanced and helped them build and scale their applications and products. He also mentored a lot of developers and students, and helped them progress in their careers.

To get to know more about Thomas you can check out his Codementor and Twitter profiles.