As a developer, it’s hilariously easy to get caught up in the complexities of the problem you’re working on. Complex problems demand attention, and this demand makes it all too easy to forget important details and questions we should be continually asking ourselves along the way. Questions like: “Why am I doing this?”, “How does this benefit our end users?”, “How will this affect us x months later”, and “Is there a better way to solve this problem?” though peripheral, are all vitally important but frequently overlooked by devs when a problem starts to get tough.
Developer tunnel-vision can be disastrous. Complex software developed without regard for these questions runs a serious risk of being delivered fundamentally inadequate; failing to deliver the value expected by end-users, or littered with technical landmines, instabilities, or bloat. Complexity in our problem-spaces is bad! However, this very complexity which plagues us is inherent to our industry; we’re paid to be problem solvers – and to solve problems which are large, intractable, and deeply complex, and there’s nothing we can really do about that!
So if we can’t simplify the problems we’re being tasked with, what can we do to reduce the complexity we’re exposed to and prevent tunnel vision? The answer lies in optimising the things we can control. By carefully selecting the tools and techniques we use from the standpoint of reducing complexity, we can expect marginal gains in the simplicity of our solution. Even if these gains individually aren’t much, when looked at holistically they’re significant! This lessens the cognitive load of your team – freeing up brainpower to not ignore those crucial peripheral questions!
Two years ago, my team was struggling with severe complexity issues. We had a monolithic aging React application, a dizzyingly vast and complex problem domain, poor user engagement, and a long list of requested features. We were forced to re-examine our development philosophy and we made a number of crucial decisions to update and simplify our React application in order to improve it and ensure its long-term health.
Early React was particularly vulnerable to producing polluted solutions when encountering complexity. Unidirectional data-flow, bloated class-based components, and layers-upon-layers of wrappers and providers meant that developing for early React required lots of effort fighting it’s internals and jumping through the hoops of the framework. The complexities of React would just compound the complexities of your problem, and contributed to the mess we’d found ourselves in.
Stateful logic has always been a difficult problem in React. Traditionally, stateful workloads have been implemented within the lifecycle methods of a component, but this has proven problematic when similar bits of stateful logic have needed to be replicated across many disconnected components in a codebase. This is technically “solvable” using patterns like render props and higher-order components, however these require invasive restructuring of component hierarchies and tend to result in convoluted codebases filled with layers-on-layers of providers, wrappers, and other abstractions in order to somehow link otherwise unrelated components by their common stateful logic. In fact, the React team coined the term “wrapper hell” for this exact problem! (see figure 1)
React released a neat solution to this on February 6th 2019 with Hooks. Hooks are reusable functions which produce side-effects within components. These side-effects could be anything, but are commonly used for tasks like managing state, interacting with APIs, and handling contexts. When integrated with functional components, hooks neatly solve the problem of “wrapper hell”, allowing components to remain stateless and outsourcing common or stateful functionality to the hook. This promotes the pattern of small, simplistic, easily reusable components; and we’ve had great success with hooks alongside with functional components. It’s worth mentioning though that hooks aren’t a perfect solution in all cases. Their inherent simplicity means that they’re sometimes not ideal for applications with large amounts of complex stateful logic – in these more extreme cases, tools such as Redux may be more appropriate. (see figure 2)
This pattern of simplicity has also bled into the wider React ecosystem. One tool our team is particularly fond of is Storybook. Storybook is a component design tool where you are able to develop and document individual components in isolation. This makes it easy to develop and demo components which might normally be hidden deep within your application with minimal fuss. It also lets you interact with your component as you’re developing it, helping you build your UI almost from the perspective of somebody actually using it – helping discover potential edge-cases and usability issues early. Developing components in isolation helps keep them simple. It promotes building small, discrete pieces of logic for your users; minimising complexity and maximising user satisfaction. Our team is so fond of this tool, we’ve actually deployed an instance of it alongside our main application in a continuous deployment environment so interested parties can play with components and prototypes that we’ve not necessarily plugged into the main application yet!
Maximising user satisfaction through simplicity is a pattern also being embraced in how React applications are tested. Traditionally, Enzyme was the de-facto React testing framework. It works in a very similar way to many other testing frameworks for backend systems – invoking classes and methods containing your business logic, and mocking out other interactions. This approach might be fine for an API where the shape of the data coming out of it is the thing we interact with and want to guard against regression. However, this is often inadequate for testing the functionality of a frontend application where we want to assert and guard how users are able to interact with and navigate through it.
React Testing Library is rapidly replacing Enzyme because it does exactly that – it renders your components and tests them by interacting with them how your users would, i.e, clicking on a button instead of calling an arbitrary function somewhere in your implementation. If for some reason that button wasn’t to render, it wouldn’t matter how well tested your underlying logic was – nobody would be able to use it! Because RTL-based tests don’t test unnecessary implementation details, we’ve noticed that they’re far less brittle and ultimately much easier to maintain over the life of a project. By changing our testing approach, we’ve been able to delete scores of Enzyme tests as a small number of RTL tests will provide the same assurances! Plus, by specifically testing user outcomes in a more representative way, we’re able to provide much more confidence in the accuracy of our solutions! (see figure 3)
In summary, our team has had great success simplifying our UI module-by-module using methodologies and tools from the modern React ecosystem. Our work in this area is still not fully done, but we’ve been continually delivering new and updated features alongside this continued refactoring effort. Our application is now simpler and less complex! This is metricated by our team’s increased velocity, the number of bugs we’ve found in our pre-prod and production environments, and our steadily growing user community. Of course, it’s still possible to develop tunnel vision when working on our refactored application – but instances now happen much further and fewer between. If your team is in a similar position as we were two years ago, I’d strongly recommend looking into your stack’s modern ecosystem and seeing if you can’t improve things in a similar way!
React hooks and stateful logic:
React testing library: