A Tip On Managing Complexity In React and Typescript

By: John Detlefs
Posted: April 06, 2020

A few years ago, I was talking to a very talented engineer about what I was working on, lamenting that the product was 'over-engineered', he stopped me and told me 'over-engineering' is a misnomer. When great engineering teams successfully collaborate, the result is a well built product that satisfies company objectives. What I was calling 'over-engineering' happened when workers sought out and attached themselves to complex issues without thoughtful attempts to reduce their complexity.

Some people call it 'speculative generality', I call it 'conflation'. Sometimes this happens because a developer wants to challenge themselves by finding a one-size-fits-all solution to their problem. Some of us do it because communication between product and technical management breaks down to the point where we don't effectively make features more manageable. Regardless, most developers are guilty of falling into this trap. I know I am.

At work we ran into this problem with our tables. We have a bunch of different tables throughout our CRUD admin app, some tables are server-side paginated, some load the data all at once. Some of them are data-rich tables, for those we use https://github.com/gregnb/mui-datatables, since we're using material-ui. We have some tables which are meant to act as form inputs for selecting items. There are a bunch of tables in our app!

This rich set of feature requirements can create a maintenance problem for your application, as we have found out. When building things from scratch, the desire to be clever and to adhere to Dont Repeat Yourself (DRY) can pull even best devs towards an inefficient approach.

Exclusive Tables

An image from Notion

Tables with Overlap

An image from Notion

Key

A: ServerSideDataTable B: ClientSideDataTable C: GeneralDataTable

Before building anything, we can't say with certainty that there will exist any code to share between the table that handles server-side paginated data and the table that handles data fetched on mount. Experience tells us that there will be some opportunity to share code, so it is easy to fall into the trap of a building one table to target the set of features encapsulated by the intersection, GeneralDataTable

For us, this approach became a maintenance burden. If our experience is any indication, the way that your codebase (ab)uses Typescript might be an indicator of conflation causing complexity. Naively, the props exclusively for the ServerSideTable, the non overlap A disjoint C, would likely be expressed via "maybe" types. Say we've done a bad job and our code is documented poorly. If we use maybe types, our lack of documentation is even worse! Without the benefit of a tight contract established by our type for C, we lose the ability to have the use of C define what props it requires. We could use merge-exclusive to have either all types for A or all types for B. This still leads to the complexity of managing the logic for what are things without complete logical overlap in the same component.

What we've done is break our GeneralDataTable into ServerSideDataTable and ClientSideDataTable. With this approach, the core logic for two fundamentally different tasks is kept distinct. We can define the type of props that are necessary for this logic in a way that is easy for all Typescript users to understand. This has already caught errors, and reduced the difficulty to juggle multiple concerns in our GeneralDataTable.

The core takeaway here is that DRY should be applied judiciously to code, but maybe not to so judiciously to your component architecture. Premature abstractions can slow you down and reduce how powerful your type-checking can be for you. Coupling your components to distinct features with their own requirements lets you build n components that are each focused on one job, rather than building one component that is handles n jobs.