Search code examples
reactjsreact-class-based-component

How can I split a custom data table up into modular components?


i'm looking for some guidance to what would be considered best practice when developing a custom data table.

Background: I've been tasked with created a table component that has both static and asynchronous loading for when the user paginates, sorts, and or searches. Currently I have a single table that handles both of these branches but its getting a bit bloated and would like to separate out the two branches.

So far I have two thoughts:

  1. Have a Table component that will display data and turn on specific features for developer (sorting, searching, paginating). Then have two higher order components that wrap the original Table component that will handle either asynchronous or static loading for when the user sorts, searches, or paginates through the Table.
  • The problem I see with this implementation is these higher order components will be quite coupled to the original Table components implementation and the one (withAsyncSubscription shown below) takes in a prop that that original Table component does not to prepare the api data to be used in the Table component.
  1. Have a Table component that will display data and turn on specific features for developer (sorting, searching, paginating). Create a wrapping component for the original Table component that will handle re-fetching data (asynchronous data loading passing in sort, search, and pagination parameters to the back-end) and another wrapping component that wraps the original Table component that will transform the original data set (static data loading performing sort, search, and pagination on the front-end).
  • The problem I see with this implementation is having to relay lots of props through this wrapping component into the original Table component.

What solution sounds better in theory and more aligned with React class based components best practices? I have already tried my first thought and it looks something like this:

export class Table extends React.Component {
       static propTypes = {
            headings: PropTypes.object,
            data: PropTypes.arrayOf(PropTypes.object),
            sortable: PropTypes.arrayOf(PropTypes.string),
            searchable: PropTypes.arrayOf(PropTypes.string),
            pageSize: PropTypes.number,
            totalRecords: PropTypes.number,
            loadData: PropTypes.func,
            loading: PropTypes.bool,
        }
        
        load() {
          // call external loadData prop if it exists
          // or
          // perform synchronous sort, search, pagination 
        }
        
        renderSortableColumns() {
          // render sort column headers if sortable prop exists
        }
        
        renderSearch() {
          // render search if searchable prop exists
        }
        
        renderPagination() {
          // render table pagiantion if pageSize prop exists
        } 
        
        
        render() {
          // render table
        }
    }
    
export function withStaticSubscription(Table) {
    return class WithStaticSubscription extends React.Component {
        static propTypes = {
            data: PropTypes.any
        }

        constructor(props) {
            super(props);
            this.load = this.load.bind(this);
            this.state = { displayData: [], totalRecords: 0 };
        }

        load(sort, search, currentPage, pageSize) {
           // perform sort, search, and pagination with original static data set
      
           // set data and pass it into original Table component
           this.setState({ displayData, totalRecords });
        }

        render = () => {
            const { displayData, totalRecords } = this.state;
            
            return <Table
                {...this.props}
                data={displayData}
                loadData={this.load}
                totalRecords={totalRecords}
            />;
        }
    };
}

export function withAsyncSubscription(Table) {
    return class WithAsyncSubscription extends React.Component {
        static propTypes = {
            loadData: PropTypes.func, // api to get new table data
            transformData: PropTypes.func // custom transformer that prepares data for table
        }

        static defaultProps = {
            loadData: () => {},
            transformData: () => {}
        }

        constructor(props) {
            super(props);
            this.load = this.load.bind(this);
            this.state = { displayData: [], totalRecords: 0 };
        }

        load = async(sort, search, currentPage, pageSize) => {
            const { loadData, transformData } = this.props;

            const searchParams = {
                search: false,
                page: currentPage + 1,
                rows: pageSize,
                sortColumn: sort.column,
                sortOrder: sort.order
            };

            this.setState((prev) => { return { ...prev, loading: true }; });

            const { data, totalRecords } = await loadData(searchParams);
            const displayData = transformData(data);

            this.setState({ totalRecords: totalRecords, displayData, loading: false });
        }

        render = () => {
            const { loading, displayData, totalRecords } = this.state;
            return <Table
                {...this.props}
                data={displayData}
                loadData={this.load}
                loading={loading}
                totalRecords={totalRecords}
            />;
        }
    };
}
    
    
export const TableWithAsyncSubscription = withAsyncSubscription(Table);
export const TableWithStaticSubscription = withStaticSubscription(Table);


Solution

  • The problem I see with this implementation is these higher order components will be quite coupled to the original Table components implementation and the one (withAsyncSubscription shown below) takes in a prop that that original Table component does not to prepare the api data to be used in the Table component.

    This isn't generally a bad thing, in fact, quite the opposite. What you are describing is essentially composition which is the right tool for the job to fix this, and the React way of solving modularisation from an architecture point of view. Essentially, your original overriding instincts are pretty correct!

    It depends if you ever intend to use withAsyncSubscription with some other component that is not a table. If you don't though, you don't gain much by the decoupling because you just moved the glue code to the consumer (see below). However the way I describe below can be useful if you want to further modify those props in ways unique to the consumer. It kind of makes everything visible and provides escape hatches with no assumption about how things are bound.

    If you really do want it further decoupled, I would not use HOCs at all but instead use the render prop pattern.

      return class WithAsyncSubscription extends React.Component {
            static propTypes = {
                loadData: PropTypes.func, // api to get new table data
                transformData: PropTypes.func // custom transformer that prepares data for table
                children: PropTypes.func
            }
    
            static defaultProps = {
                loadData: () => {},
                transformData: () => {}
                children: () => null
            }
    
            constructor(props) {
                super(props);
                this.load = this.load.bind(this);
                this.state = { displayData: [], totalRecords: 0 };
            }
    
            load = async(sort, search, currentPage, pageSize) => {
                const { loadData, transformData } = this.props;
    
                const searchParams = {
                    search: false,
                    page: currentPage + 1,
                    rows: pageSize,
                    sortColumn: sort.column,
                    sortOrder: sort.order
                };
    
                this.setState((prev) => { return { ...prev, loading: true }; });
    
                const { data, totalRecords } = await loadData(searchParams);
                const displayData = transformData(data);
    
                this.setState({ totalRecords: totalRecords, displayData, loading: false });
            }
    
            render = () => {
                const { loading, displayData, totalRecords } = this.state;
                return this.props.children({
                    data: displayData
                    loadData: this.load
                    loading: loading
                    totalRecords: totalRecords
                })
            }
        };
    

    Now in your use case, you would do:

    <WithAsyncSubscription>
        {(asyncProps) => <Table {...asyncProps} /> {/* You can add any other props from the containing component here */}
    <WithAsyncSubscription>
    
    

    The good thing about this is that if you need to combine multiple features you can do:

    <WithAnotherFeature>
      {(featureProps) =>
        <WithAsyncSubscription>
            {(asyncProps) => <Table {...asyncProps} {...featureProps} /> {/* You can add any other props from the containing component here */}
        <WithAsyncSubscription>
      }
    </WithAnotherFeature>
    

    Here I spread them all consecutively but since there's no assumed order it's better than HOCs where you can accidentally couple them to each other and they start becoming order dependent. Instead the merging logic is left to the consumer to bind.

    Worth noting, React hooks makes this much nicer than render props pattern, but you can't use that here unless you convert your old-style class components to the "new" functional components. Hooks have basically killed HOCs as an abstraction for the most part. It's not to say that HOCs are bad -- just less common. Hooks/render props is a lot closer though and basically doing the same thing. Generally these can be a little nicer for the consumer as they are mechanisms to achieve composition of state logic that don't make as many assumptions as a plain HOC (like what you are going to render and how the props map).

    Up to you if it's worth the time/effort to convert the components to use hooks though. I wouldn't if render props works for you.