Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

react redux architecting table actions and reducers

I have server driven data being displayed in tables on multiple pages.

I currently have the following table actions

pageChange
pageSizeChange
sort
load
loaded

I have filters on some pages which need to trigger the load.

I have multiple entities which use this functionality, which will share most the above logic, but will need separate load functionality defining.

My thought was to have actions which take the table ID as a parameter, and then have a createTableReducer function which would also take this ID and would mount table nodes within entities, similarly to createModelReducer in react-redux-form

How could I trigger the entity specific load actions from my generic actions without using something like redux saga?

I was wondering about creating a higher order component and passing it the load function but I don't think this would solve my issue. I could also call both the change action and the load action from the table component itself, but this doesn't' seem like a nice solution.

like image 941
Tom Avatar asked Mar 07 '16 10:03

Tom


1 Answers

So, this is a pretty open-ended question and possibilities for implementing this are almost limitless... but, I'll take a shot at it anyhow. This answer isn't doctrine, it's just one possible way to do it.

Let's start by wishing we could plop our tables wherever we want. To differentiate one table from another, all we have to do is pass a tableId property that links a Table to the corresponding data in the store.

<Table tableId='customers' />
<Table tableId='products' />
<Table tableId='orders' />
<Table tableId='transactions' />

Next, let's define a state shape to work with. Each top-level key matches a tableId we wish to store data for. You can make this shape be whatever you'd like, but I'm providing this so you have to do less mental visualization as I reference things in later code. You can also name the top-level tableId keys whatever you want, just so long as you reference them consistently.

{
   customers: {
     page: 0,
     sortBy: 'id',
     cols: [
       {id: 'id',         name: 'name'},
       {id: 'username',   name: 'Username'},
       {id: 'first_name', name: 'First Name'},
       {id: 'last_name',  name: 'Last Name'},
       ...
     ],
     rows: [
       {id: 1, username: 'bob', first_name: 'Bob', last_name: 'Smith', ...},
       ...
     ]
  },
  products: {
    ...
  },
  orders: {
    ...
  }
  ...
}

Now, let's get the hard part out of the way: the Table container. It's a lot to digest all at once, but don't worry, I'll break down the important bits individually.

containers/Table.js

import React from 'react';
import {connect} from 'react-redux';
import actions as * from './actions/';

const _Table = ({cols, rows, onClickSort, onClickNextPage}) => (
  <div>
    <table>
      <thead>
        <tr>
         {cols.map((col,key) => (
           <Th key={key} onClickSort={onClickSort(col.id)}>{col.name}</Th>
         )}
        </tr>
      </thead>
      <tbody>
        {rows.map((row,key) => ...}
      </tbody>
    </table>
    <button onClick={onClickNextPage}>Next Page</button>
  </div>
);

const Table = connect(
  ({table}, {tableId}) => ({    // example: if tableId = "customers" ...
    cols: table[tableId].cols,  // state.table.customers.cols
    rows: table[tableId].rows   // state.table.customers.rows
  }),
  (dispatch, {tableId}) => ({
    onClickSort: columnId => event => {
      dispatch(actions.tableSortColumn(tableId, columnId));
      // example: if user clicks 'last_name' column in customers table
      // dispatch(actions.tableSortColumn('customers', 'last_name'));
    },
    onClickNextPage: event => {
      dispatch(actions.tableNextPage(tableId))
    }
  })
)(_Table);

export default Table;

If you only learn one thing from this post, let it be this:

The thing to notice here is that mapStateToProps and mapDispatchToProps accepts a second argument called ownProps.

// did you know you can pass a second arg to these functions ?
const MyContainer = connect({
  (state, ownProps) => ...
  (dispatch, ownProps) => ...
})(...);

In the container I wrote above, I'm destructuring each of the vars we care about.

// Remember how I used the container ?
// here ownProps = {tableId: "customers"}
<Table tableId="customers" />

Now look at how I used connect

const Table = connect(
  // mapStateToProps
  ({table}, {tableId}) => ...
    // table = state.table
    // tableId = ownProps.tableId = "customers"


  // mapDispatchToProps
  (dispatch, {tableId}) => ...
    // dispatch = dispatch
    // tableId = ownProps.tableId = "customers"
)(_Table);

This way, when we're creating the dispatch handlers for the underlying component, (_Table), we'll have the tableId available to us within the handler. It's actually kinda nice that the _Table component itself doesn't even have to concern itself with the tableId prop if you don't want it to.

Next notice the way the onClickSort functions is defined.

onClickSort: columnId => event => {
  dispatch(actions.tableSortColumn(tableId, columnId));
}

The _Table component passes this function to Th using

<Th key={key} onClickSort={onClickSort(col.id)}>{col.name}</Th>

See how it only sends in the columnId to the handler here ? Next, we'll see how Th is the one to send the event, which finally dispatches the action.

components/Table/Th.js

import React from 'react';

const Th = ({onClickSort, children}) => (
  <th>
    <a href="#sort" onClickSort={event => {
      event.preventDefault();
      onClickSort(event);
    }}>{children}</a>
  </th>
);

export default Th;

It's not necessary to keep the event if you don't want it, but I figured I'd show you how to attach it in case you wanted to utilize it for something.

Moving along ...

actions/index.js

Your actions file is going to look pretty standard. Notice we provide access to tableId in each of the table actions.

export const TABLE_SORT_COLUMN = 'TABLE_SORT_COLUMN';
export const TABLE_NEXT_PAGE = 'TABLE_NEXT_PAGE';

export const tableSortColumn = (tableId, columnId) => ({
  type: TABLE_SORT_COLUMN, payload: {tableId, columnId}
});

export const tableNextPage = (tableId) => ({
  type: TABLE_NEXT_PAGE, payload: {tableId}
});

...

Lastly, your tableReducer might look something like this. Again, nothing too special here. You'll do a regular switch on the action type and then update state accordingly. You can affect the appropriate part of the state using action.payload.tableId. Just remember the state shape I proposed. If you choose a different state shape, you'll have to change this code to match

const defaultState = {
  customers: {
    page: 0,
    sortBy: null,
    cols: [],
    rows: []
  }
};

// deep object assignment for nested objects
const tableAssign = (state, tableId, data) =>
  Object.assign({}, state, {
    [tableId]: Object.assign({}, state[tableId], data)
  });

const tableReducer = (state=defaultState, {type, payload}) => {
  switch (type) {
    case TABLE_SORT_COLUMN:
      return tableAssign(state, payload.tableId, {
        sortBy: payload.columnId
      });
    case TABLE_NEXT_PAGE:
      return tableAssign(state, payload.tableId, {
        page: state[payload.tableId].page + 1
      });
    default:
      return state;
  }
};

Remarks:

I'm not going to go into the async loading of the table data. Such a workflow is already nicely detailed in the redux docs: Async Actions. Your choice of redux-thunk, redux-promise, or redux-saga is up to you. Choose whichever you understand best! The key to implementing TABLE_DATA_FETCH properly is making sure you dispatch the tableId (along with any other parameters you need) like I did in the other onClick* handlers.

like image 184
Mulan Avatar answered Sep 27 '22 23:09

Mulan