Things I've learned from writing React apps
February 26, 2019 in JavaScriptI’ve been writing a lot of React code lately, perhaps more than anytime before, and that challenged my opinions about good React architecture. Instead of praising functional purity and abstract concepts like “perpetual data loop,” I now value simplicity and the ability to isolate my code into nice clean functions.
So, here is some accumulated practical wisdom.
Don’t put all the data into Redux
I came into liking React through re-frame - the fantastic ClojureScript React framework. (I still think re-frame is the bomb.)
So when I started writing JavaScript React applications, I found that Redux comes closest to re-frame state management, so that’s what I advocated, along with the re-frame convention of putting all the data into one big structure.
I was wrong. In Clojurescript, with its immutable data structures, storing everything in one or several state atoms is convenient. In JavaScript, it’s going against the language conventions. So when you try to put all the data into redux, you are continuously battling against many popular libraries, and for no good reason.
Now I only put actual business domain data into redux. It’s data and logic that genuinely doesn’t care about the view layer. Thanks to this, I’ve been able to create a web SPA and a React Native app that share the same Redux (well, Rematch - more about that below) store. The promise of code reuse came true!
Use a good form abstraction
Forms are the hardest UI element to program. They have validations, and they have errors, conditional blocks, repeated blocks, et cetera. So a nimble abstraction to help you describe forms efficiently is a significant boost to productivity - and the quality of your app because if you do your forms from scratch, you usually settle for the most primitive approaches.
I started with Redux Form, which is a great form toolkit, abstract enough to adapt to your needs - even in native apps - but succinct so you’re only filling in the essential parts of your business logic.
However, on the “don’t put all the data on the redux” wave, I discovered that Redux Form’s author - Erik Rasmussen - is also making Final Form - a form toolkit that’s derived from Redux Form in API, decoupled from Redux and still as powerful and concise.
When I’m making a form with final-form, I feel like I’m working on the business logic, and not on the plumbing. For example, it doesn’t take any effort to add a validation to a form field. It reminds me of the good old Rails days when formtastic was all you needed to make forms look good (as long as you rendered them on the server).
Put UI-related and navigation code with the views
Let’s consider the form submit
function. Where does it belong? Some time ago I thought it belongs with the business logic, perhaps somewhere
in the Redux sagas or effects. So of course, this made me pass the router object, among other dependencies, into the business logic. Sometimes this is not an easy task at all,
especially in native apps.
Eventually, it hit me - submit
does user-facing navigation, it produces error messages for the user - it is a part of the user interface!
I started defining submit()
functions in the form component files, and they felt much more in place. (Of course, the code that does data
processing is a part of the logic layer and it’s defined elsewhere. I pass the action creator to the submit function as a parameter.)
Similarly, validations for forms live in an intermediate layer where they’re related to business logic, but they still produce user-facing error messages.
Don’t use Redux-Saga
I liked Redux-Saga before I saw the async/await JS feature. It’s a pity I only saw it last year. Once I tried replacing some of my sagas with async/await code, it was 10 times cleaner and easier to understand. So now I use async functions everywhere. One huge benefit is that they’re easier to explain to other people: sagas are always a mental puzzle.
Use a good Redux wrapper that couples action creators and reducers
I don’t care what the purist benefits are, but putting action creators in one file and reducers in another is a ridiculous waste of time and mental capacity. (And by the way, re-frame never made you do that. I like re-frame!)
My first attempt to bring actions and reducers together was to use redux-act, but I quickly moved on to rematch that creates more structure for you.
Use selectors
Much of my business logic lives in reselect selectors. Selectors derive data from your raw models. Selectors are cacheable, composable and reusable. If you have too much logic in your views, chances are, the logic belongs in selectors. There it’s decoupled from the view, and easy to test (and understand!)
Use local state when it’s local
There’s nothing wrong with declaring some state in a component and using it in the component’s children. It’s only a problem if the state is needed elsewhere and you create duplication.
To put it another way: it’s much better to code one component with an isolated state than to smear that code over your action creators, reducers, store, and connect()
call.
Moreover, with the recently released useState hook, you don’t even need a class component for this.
Don’t use Redux at all?
More than once I saw my Redux store degenerate into a dumb ad-hoc database with very basic create/update/delete operations. At that point, why not dispense with the store and use some other mechanism of separating data mutation from data subscription? In 2019, this is not a new concept, of course. Many people use Apollo Client as the replacement. I have had success with PouchDB.
I’ve found that if you have some other way to subscribe to data, you don’t need Redux. Think hard about whether the overhead of writing all those reducers is valuable for your app, or it’s just moving data from one place to another.
(You can still use reselect without Redux.)
Tests are not the most important thing
In my indie projects, I don’t do too much testing. I need to iterate fast, pivot fast, and testing everything would slow me down. So I test crucial parts, and I leave most of the application code untested.
However, I still need to know that the app works, and without too much manual testing — because I don’t have much time for manual testing either!
That’s why I’ve found TypeScript to be a tremendous help. With TypeScript, I have some confidence that my code is correct - based on typings - and I also get code completion and IDE features. TypeScript is not like most languages that compile to JavaScript in that the TypeScript code is word-for-word JavaScript, with added typings. You don’t need to learn any new language features. There’s no interop or runtime overhead.
Simple code - that’s my goal
I value splitting the app into small modules that serve a single purpose. That purpose should be easy to understand.
I value having most business logic in pure synchronous functions. It’s okay to assemble them into asynchronous flows, but the async flows are more comprehensible if they consist of clearly isolated synchronous calls.
Many values pursued with the goal to make testing easier - such as small modules and pure functions - are valuable for understanding the code, in the first place. However, “code that is easy to understand” is a vaguer concept than “code with 100% testing coverage”, and engineers don’t like vague concepts.
So I talk about testing more - but in my heart, I want the code to be clear.
And that’s what React is all about, now just as four years ago.
Liked the post? Treat me to a coffee