Bringing the simplicity of React to your entire stack.
Tom Dawes
Posted on October 23, 2019
As developers, we’ve found that React has been a breath of fresh air in the otherwise very complex world of JavaScript frameworks. After a couple of years of obsessing about making programming more accessible, we’ve now convinced ourselves — and would like to convince you — that the rest of your product stack can and should be just as simple.
In this post, we’ll review why React feels so simple, where we can apply similar principles, and how we plan to make this a reality for everyone.
What makes React just so good?
There are a lot of good things to say about React and its philosophy. For instance, developers would often praise React for being “functional” and “declarative”. But to summarise it in plain English, our view is that React’s simplicity boils down to three things:
- It’s simple — Modern React components are just plain functions. They take an input (props, state and context) and output React elements. Developers only have to interact with a minimal API (which is made intuitive through JSX), don’t have to worry about asynchronous behaviour (React will re-render each component as asynchronous behaviour yields updates) and can write very readable code that is easy to integrate with type-checkers such as TypeScript.
- It’s automated — Most developers never have to think about the difference between a React element and an HTML element — for all intents and purposes, they’re the same. Developers can write their code to generate React elements and stop worrying about what happens after that. React is quietly handling all of the grunt-work — it determines the minimal set of DOM transformations, commits those changes in a consistent manner, handles the interaction with browser APIs, and ensures that everything gets re-rendered efficiently if anything changes. The developer only has to occasionally step in where React is unable to do all the work by itself (e.g. specifying explicit keys to help with reconciliation).
- It’s open — Thanks to its design and its philosophy, React has become a popular and powerful ecosystem. It fits the real-world, rather than trying to force a one-size-fits-all solution to every project. It integrates easily with a range of CSS frameworks, allows developers to extend functionality by combining native hooks together to form custom hooks, and can be generalised beyond web and native applications, to render VR applications, PDF files and loads more. And a new form of composability was recently introduced through React Hooks.
The problem with everything else
Building real-life applications requires a lot more than just visual components — you’ll typically need database connections, API requests, browser feature integration and domain logic.
Technologies like GraphQL have made it easier to move some of the complexity to the back-end and query the data you need directly from your React components. But that’s just to query raw data. This doesn’t help you with the hard technical bits, like managing user sessions, authentication, and front-end state management. Likewise, React Hooks can often simplify data management, but the built-in hooks only offer a concept of local state and provide an injection mechanism for global state frameworks.
So most developers end up adopting a “state management” framework like Redux to manage all of this data in a single place and provide structure around accessing and updating it. But there is very little consistency between how the many redux-*
libraries interact — some ship with reducers and custom actions, supplying their own logic, while others use middleware to integrate with existing actions. Some integrate with React directly, using component lifecycles to trigger logic while others rely on manual interaction through react-redux
. In fact in most cases, you have to do both.
Most importantly, Redux itself is failing to meet the three criteria which have made us fall in love with React. It’s not simple because you can’t just call functions — you need things like “action creators” and “reducers” just for a basic implementation, and then you need additional abstractions such as “thunks” or “sagas” to deal with more complex behaviours. It’s not automated — in fact, it’s very low-level and requires a lot of boilerplate, even for very simple data transformations. And it is open in principle, but fails to meet the mark in practice because of overly-complex APIs and lack of normalisation.
Thankfully, Redux isn’t the only option and there any many alternatives that sometimes achieve one or two of the targets — but nothing has managed to hit all three.
Thankfully, Redux isn’t the only option and there any many alternatives that sometimes achieve one or two of the targets — but nothing has managed to hit all three.
Our Vision
We believe that the next generation of state management libraries will have to:
- manage data with plain and simple functions, both on the client (actions) and server (lambdas);
- provide a declarative way to deal with data, leaving the framework to automate when and how to fetch data or manage subscriptions, yet letting you specify what is shown while data is loading; and
- be extensible via a plugin system, allowing developers to easily add functionality and integrate with whichever backend technologies are best fit for purpose.
We have built an early implementation of the principles above, which you can find on CodeSandbox here. All of the code snippets below are taken from that project.
You should be able to define updates to a remote database state by writing simple JSON-like mutations:
import { newId } from "./effects"
export const likeMessage = (id: string) => {
db.messages[id].likes++;
};
export const postMessage = (text: string) => {
const id = newId();
db.messages[id] = {
text,
author: auth.username,
likes: 0,
roomId: state.roomId,
};
};
And then use your data and actions from any component with zero boilerplate:
export const Message = ({ id }: { id: string }) => {
const { db } = useData(() => <LoadingSpinner />);
const { likeMessage } = useActions();
return (
<div>
<h2>{db.messages[id].text}</h2>
<span>{db.messages[id].likes}</span>
<button onClick={() => likeMessage(id)}>+1</button>
</div>
);
};
Under the hood, a smart framework will automatically connect your components to the store, track which parts of the state are being used and manage the appropriate subscriptions, display any necessary loading spinners (e.g. while data is being fetched asynchronously), and selectively re-render components when needed.
And without the unnecessary abstraction and boilerplate, TypeScript can then easily infer all the types in your code base on a few provided data types.
The progress so far
You can see an experimental, self-contained implementation of the above concepts on CodeSandbox:
We have also open-sourced a more stable version of our production framework at https://github.com/prodo-dev/prodo. The latter includes a lot of functionality that we haven’t discussed here, such as support for streams/subscriptions, time-travelling dev-tools and simplified unit-testing. Please consider giving this repo a GitHub star if you like the direction we’re taking.
Meanwhile, we’re also building a suite of next-generation developer tools to make front-end development more intuitive and automated — for example, by letting you generate tests directly from a GUI, or by automatically generating type annotations for you using machine learning.
If you’re interested in the topics we’ve discussed above, you can also join our Slack community to continue the discussion!
The Prodo team is a group of full-stack developers who share a passion for simplicity and automation. Our mission is to make application development as fast and enjoyable as possible, and we believe that simple, declarative programming has a huge part to play in making this happen.
Posted on October 23, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.