Comparing reactivity models - React vs Vue vs Svelte vs MobX vs Solid vs Redux
Mateo Hrastnik
Posted on August 4, 2020
If you're reading this article you're probably already familiar with the concept of reactive programming, but just in case, let me explain what is it and why it's great.
When you're writing code, the commands get executed in a particular order - from top to bottom. So if you write...
let x = 10;
let y = x + 5;
Then y
will equal 15, and that's just what we expect, but what happens to y
if we then change the value of x
to 20? The answer is simple - nothing happens to y
, its value will still be 15.
The problem is that the second line of code doesn't say let y be the value of x plus 5
. What it instead says is let y be the value of x at the moment of declaration, plus 5
. That's because the values of x
and y
are not reactive. If we are to change the value of x
, the value of y
doesn't change with it.
let x = 10;
let y = x + 5;
let x = 20;
console.log(y); // 15
So how do we declare the variable y to be the value of x plus 5
? That's where reactive programming comes in. Reactive programming is a way of programming that makes it possible solve this problem, but it's just a concept - the actual implementation can vary from library to library.
This article will compare some of the more popular reactivity models in the JS ecosystem - especially the ones found in the UI frameworks and libraries. After all, UI is just a function of state, meaning that UI has to react to changes in state.
In order to compare the different approaches to solving this problem, I'll demonstrate how to create a simple To-do app using different frameworks and libraries. We'll keep the UI as minimal as possible. After all, we are comparing reactivity models, and not UI libraries.
Here's how the end product is gonna look like.
1. React
It's 2020 in the world of web development, so you've probably heard of React. It's a fantastic UI library, and, as its name would suggest, React can react to stuff. Namely, it can react to changes in state.
Here's how a basic todo app looks like in React.
import React, { useEffect, useState } from "react";
export default function App() {
const [todoList, setTodoList] = useState([
{ id: 1, task: "Configure ESLint", completed: false },
{ id: 2, task: "Learn React", completed: true },
{ id: 3, task: "Take ring to Mordor", completed: true },
]);
const completedTodoList = todoList.filter((t) => t.completed === true);
const notCompletedTodoList = todoList.filter((t) => t.completed === false);
function createTodo(task) {
setTodoList([...todoList, { id: Math.random(), task, completed: false }]);
}
function removeTodo(todo) {
setTodoList(todoList.filter((t) => t !== todo));
}
function setTodoCompleted(todo, value) {
const newTodoList = todoList.map((t) => {
if (t === todo) return { ...t, completed: value };
return t;
});
setTodoList(newTodoList);
}
function addTodo() {
const input = document.querySelector("#new-todo");
createTodo(input.value);
input.value = "";
}
useEffect(() => {
console.log(todoList.length);
}, [todoList]);
return (
<div>
<input id="new-todo" />
<button onClick={addTodo}>ADD</button>
<div>
<b>Todo:</b>
{notCompletedTodoList.map((todo) => {
return (
<div key={todo.id}>
{todo.task}
<button onClick={() => setTodoCompleted(todo, true)}>
Complete
</button>
</div>
);
})}
</div>
<div>
<b>Done:</b>
{completedTodoList.map((todo) => {
return (
<div key={todo.id}>
{todo.task}
<button onClick={() => removeTodo(todo)}>Delete</button>
<button onClick={() => setTodoCompleted(todo, false)}>
Restore
</button>
</div>
);
})}
</div>
</div>
);
}
In React, reactive state is created using the useState
hook - it returns the state itself, and a setter function to update the state.
When the setter is called the whole component re-renders - this makes it really simple to declare derived data - we simply declare a variable that uses the reactive state.
In the example above, todoList
is a list of todo objects, each having a completed
attribute. In order to get all the completed todos we can simply declare a variable and filter the data we need.
const completedTodoList = todoList.filter((t) => t.completed === true);
The state updater function can take the new state directly, or we can use an updater function that receives the state as the argument and returns the new state. We have to be careful not to mutate state so when we have some complex state like an object or an array we have to use some ugly tricks like in the setTodoCompleted
function above.
It's possible to run a function whenever some reactive state changes using the useEffect
hook. In the example we log the length of the todoList whenever it changes. The first argument to useEffect is the function we want to run, and the second is a list of reactive values to track - whenever one of these values changes the effect will run again.
There's one downside to Reacts reactivity model - the hooks (useState and useEffect) have to always be called in the same order and you can't put them inside an if
block. This can be confusing for beginners, but there are lint rules that can help warn you if you accidentally make that mistake.
2. Vue
<template>
<div>
<input id="new-todo" />
<button @click="addTodo">ADD</button>
<div>
<b>Todo:</b>
<div v-for="todo in notCompletedTodoList" :key="todo.id">
{{ todo.task }}
<button @click="setTodoCompleted(todo, true)">Complete</button>
</div>
</div>
<div>
<b>Done:</b>
<div v-for="todo in completedTodoList" :key="todo.id">
{{ todo.task }}
<button @click="removeTodo(todo)">Delete</button>
<button @click="setTodoCompleted(todo, false)">Restore</button>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, watchEffect } from "vue";
export default {
setup() {
const todoList = ref([
{ id: 1, task: "Configure ESLint", completed: false },
{ id: 2, task: "Learn React", completed: true },
{ id: 3, task: "Take ring to Mordor", completed: true },
]);
const completedTodoList = computed(() =>
todoList.value.filter((t) => t.completed === true)
);
const notCompletedTodoList = computed(() =>
todoList.value.filter((t) => t.completed === false)
);
function createTodo(task) {
todoList.value.push({ id: Math.random(), task, completed: false });
}
function removeTodo(todo) {
todoList.value = todoList.filter((t) => t !== todo);
}
function setTodoCompleted(todo, value) {
todo.completed = value;
}
function addTodo() {
const input = document.querySelector("#new-todo");
createTodo(input.value);
input.value = "";
}
watchEffect(() => {
console.log(todoList.value.length);
});
return {
completedTodoList,
notCompletedTodoList,
addTodo,
setTodoCompleted,
removeTodo,
};
},
};
</script>
- Note: I'm using the new Composition API available in Vue 3.0+ that's still in beta but should be available soon.
In Vue we can declare reactive values using the ref
function from the Composition API. It returns a reactive value with a value
property that tracks everytime you access it. This is so it can actually react to changes - rerun effects and recompute derived values.
We can declare derived values using the computed
function. It takes a function and return the derived value - any reactive value accessed in this function is considered a dependency and if it changes, the derived value is also recomputed.
Updating state is as simple as writing to the .value
prop of reactive data. Arrays can be changed directly using push
, pop
, splice
and other array methods.
We can run effects when some data changes using watchEffect
- it takes a function that runs whenever a reactive value used inside changes.
3. Svelte
Svelte uses a "radical new approach" to building UI - it's a compiler that generates code and leaves no traces of the framework at runtime.
<script>
let todoList = [
{ id: 1, task: 'Configure ESLint', completed: false },
{ id: 2, task: 'Learn React', completed: true },
{ id: 3, task: 'Take ring to Mordor', completed: true },
];
$: completedTodoList = todoList.filter(t => t.completed === true);
$: notCompletedTodoList = todoList.filter(t => t.completed === false);
function createTodo(task) {
todoList = [...todoList, { id: Math.random(), task, completed: false }];
}
function removeTodo(todo) {
todoList = todoList.filter(t => t !== todo);
}
function setTodoCompleted(todo, value) {
todo.completed = value;
todoList = todoList
}
function addTodo() {
const input = document.querySelector('#new-todo');
createTodo(input.value);
input.value = '';
}
$: console.log(todoList.length);
</script>
<div>
<input id="new-todo" />
<button on:click={addTodo}>ADD</button>
<div>
<b>Todo:</b>
{#each notCompletedTodoList as todo (todo.id)}
<div>
{todo.task}
<button on:click={() => setTodoCompleted(todo, true)}>Complete</button>
</div>
{/each}
</div>
<div>
<b>Done:</b>
{#each completedTodoList as todo (todo.id)}
<div>
{todo.task}
<button on:click={() => removeTodo(todo)}>Delete</button>
<button on:click={() => setTodoCompleted(todo, false)}>Restore</button>
</div>
{/each}
</div>
</div>
With Svelte, any variable declared with let
can be reactive. Derived data is declared with the $:
label, which is valid, albeit uncommon, Javascript sytax. Any variable referenced on the lines marked with $:
is marked as a dependency of the derived variable.
The $:
can also be used to trigger effects. Logging the number of todos in the list is as simple as
$: console.log(todoList.length);
Updating state can be tricky - state updates only when we write to a variable, this is why you can sometimes see code like this
todoList = todoList;
Svelte also takes pride in being fast. It's one of the fastest frameworks out there since it's a compiler that optimises itself away and leaves only pure, speedy JS in its place.
4. MobX
MobX is a state management solution and can be used with React, Vue or any UI library. I'll show its usage with React, but keep in mind it can be used with anything, even vanilla JS.
import "mobx-react-lite/batchingForReactDom";
import React from "react";
import { observable, autorun } from "mobx";
import { observer } from "mobx-react";
const state = observable({
todoList: [
{ id: 1, task: "Configure ESLint", completed: false },
{ id: 2, task: "Learn React", completed: true },
{ id: 3, task: "Take ring to Mordor", completed: true },
],
get completedTodoList() {
return this.todoList.filter((t) => t.completed === true);
},
get notCompletedTodoList() {
return this.todoList.filter((t) => t.completed === false);
},
});
function createTodo(task) {
state.todoList.push({ id: Math.random(), task, completed: false });
}
function removeTodo(todo) {
state.todoList = state.todoList.filter((t) => t !== todo);
}
function setTodoCompleted(todo, value) {
todo.completed = value;
}
function addTodo() {
const input = document.querySelector("#new-todo");
createTodo(input.value);
input.value = "";
}
autorun(() => {
console.log(state.todoList.length);
});
const App = observer(function App() {
const { notCompletedTodoList, completedTodoList } = state;
return (
<div>
<input id="new-todo" />
<button onClick={addTodo}>ADD</button>
<div>
<b>Todo:</b>
{notCompletedTodoList.map((todo) => {
return (
<div key={todo.id}>
{todo.task}
<button onClick={() => setTodoCompleted(todo, true)}>
Complete
</button>
</div>
);
})}
</div>
<div>
<b>Done:</b>
{completedTodoList.map((todo) => {
return (
<div key={todo.id}>
{todo.task}
<button onClick={() => removeTodo(todo)}>Delete</button>
<button onClick={() => setTodoCompleted(todo, false)}>
Restore
</button>
</div>
);
})}
</div>
</div>
);
});
export default App;
With MobX we first pass some data to observable
to make it observable. Then we can use the state just as we would use plain old JS data.
We can declare derived data by setting a getter function on the object passed to observable
- this makes MobX optimise the value by caching the return value and only recomputing it when some observable value used by the getter changes.
Updating values is very simple - we can use all the common array methods like push, pop, slice etc. on observable arrays.
When we mark a React component with the observer
HOC MobX will track all observable and computed values used in the component and re-render the component every time those values change. The only caveat is that MobX doesn't actually track usage, but rather it tracks data access, so you have to make sure you access the data through a property inside the observer component.
const state = observable({ count: 10 });
const count = state.count;
// This will not re-render since count no observable
// state was _accessed_ in the component
const ComponentBad = observable(() => {
return <h1>{count}</h1>;
});
// This will re-render since count is accessed inside
const ComponentGood = observable(() => {
return <h1>{state.count}</h1>;
});
Running effects is as simple as passing the effect to autorun
. Any observable or computed values accessed in the function become the effect dependency - when they change, the effects re-runs.
5. Solid
Solid is a declarative JavaScript library for creating user interfaces. It's kinda like if React and Svelte had a baby. Here's how it looks:
import { createEffect, createMemo, createSignal } from "solid-js";
import { For } from "solid-js/dom";
export default function App() {
const [todoList, setTodoList] = createSignal([
{ id: 1, task: "Configure ESLint", completed: false },
{ id: 2, task: "Learn React", completed: true },
{ id: 3, task: "Take ring to Mordor", completed: true },
]);
const completedTodoList = createMemo(() =>
todoList().filter((t) => t.completed === true)
);
const notCompletedTodoList = createMemo(() =>
todoList().filter((t) => t.completed === false)
);
function createTodo(task) {
setTodoList([...todoList(), { id: Math.random(), task, completed: false }]);
}
function removeTodo(todo) {
setTodoList(todoList().filter((t) => t !== todo));
}
function setTodoCompleted(todo, value) {
setTodoList(
todoList().map((t) => {
if (t === todo) return { ...t, completed: value };
return t;
})
);
}
function addTodo() {
const input = document.querySelector("#new-todo");
createTodo(input.value);
input.value = "";
}
createEffect(() => {
console.log(todoList().length);
});
return (
<div>
<input id="new-todo" />
<button onClick={addTodo}>ADD</button>
<div>
<b>Todo:</b>
<For each={notCompletedTodoList()}>
{(todo) => {
return (
<div key={todo.id}>
{todo.task}
<button onClick={() => setTodoCompleted(todo, true)}>
Complete
</button>
</div>
);
}}
</For>
</div>
<div>
<b>Done:</b>
<For each={completedTodoList()}>
{(todo) => {
return (
<div key={todo.id}>
{todo.task}
<button onClick={() => removeTodo(todo)}>Delete</button>
<button onClick={() => setTodoCompleted(todo, false)}>
Restore
</button>
</div>
);
}}
</For>
</div>
</div>
);
}
We can create observable state using createSignal
. It returns a tuple with a getter and a setter function.
To create derived data we can use createMemo
. It takes a function returning the derived value, and any getter function called in the function is marked as a dependency. You know the drill, dependency changes - derived value recomputes.
Effects are created using a similar - createEffect
function that also tracks dependencies, but instead of returning values it just runs some arbitrary effect.
State can be updated using the setter function returned from createSignal
and calling it with the new state.
State can also be created and updated with createState
which returns a more React-like tuple with the state object and a setter function.
Solid looks and reminds of React with hooks, but there are no Hook rules, or concern about stale closures.
6. Redux
Redux is a predictable state container for JavaScript apps. It's often used with React so I too went down that road.
import React from "react";
import { createSlice, configureStore } from "@reduxjs/toolkit";
import { Provider, useSelector, useDispatch } from "react-redux";
const todoSlice = createSlice({
name: "todo",
initialState: {
todoList: [
{ id: 1, task: "Configure ESLint", completed: false },
{ id: 2, task: "Learn React", completed: true },
{ id: 3, task: "Take ring to Mordor", completed: true }
]
},
reducers: {
createTodo(state, { payload: task }) {
state.todoList.push({ id: Math.random(), task, completed: false });
},
removeTodo(state, { payload: id }) {
state.todoList = state.todoList.filter((t) => t.id !== id);
},
setTodoCompleted(state, { payload: { id, value } }) {
state.todoList.find((t) => t.id === id).completed = value;
}
}
});
const selectors = {
completedTodoList(state) {
return state.todoList.filter((t) => t.completed === true);
},
notCompletedTodoList(state) {
return state.todoList.filter((t) => t.completed === false);
}
};
const store = configureStore({
reducer: todoSlice.reducer
});
// Create a cache to keep old values in.
// We use this to compare previous and next values and react only
// to parts of state we want.
const prevState = { todoList: undefined };
store.subscribe(() => {
const state = store.getState();
const prevTodoList = prevState.todoList;
const todoList = state.todoList;
if (prevTodoList !== todoList) {
console.log(todoList.length);
}
});
function App() {
const dispatch = useDispatch();
const completedTodoList = useSelector(selectors.completedTodoList);
const notCompletedTodoList = useSelector(selectors.notCompletedTodoList);
function addTodo() {
const input = document.querySelector("#new-todo");
dispatch(todoSlice.actions.createTodo(input.value));
input.value = "";
}
return (
<div>
<input id="new-todo" />
<button onClick={addTodo}>ADD</button>
<div>
<b>Todo:</b>
{notCompletedTodoList.map((todo) => {
return (
<div key={todo.id}>
{todo.task}
<button
onClick={() =>
dispatch(
todoSlice.actions.setTodoCompleted({
id: todo.id,
value: true
})
)
}
>
Complete
</button>
</div>
);
})}
</div>
<div>
<b>Done:</b>
{completedTodoList.map((todo) => {
return (
<div key={todo.id}>
{todo.task}
<button
onClick={() => dispatch(todoSlice.actions.removeTodo(todo.id))}
>
Delete
</button>
<button
onClick={() =>
dispatch(
todoSlice.actions.setTodoCompleted({
id: todo.id,
value: false
})
)
}
>
Restore
</button>
</div>
);
})}
</div>
</div>
);
}
export default () => (
<Provider store={store}>
<App />
</Provider>
);
Note that we use Redux through Redux Toolkit - the recommended approach to writting Redux with good defaults and some shortcuts to avoid writing lots of boilerplate code.
One thing you'll notice is the <Provider>
component wrapping the whole app. This makes it possible for our app to access the store anywhere in the component tree. Internally it uses Reacts context API.
To define the initial state we use the createSlice
function and pass it the initialState
along with some reducers and the function returns the Redux store.
Reducers are usually described as pure functions that receive two arguments - the current state and an action - and return completely new state without touching the old one. However, with Redux Toolkit, when you define a reducer, the toolkit internally uses Immer so you can directly mutate the state object. The toolkit also creates an action creator that can trigger this reducer.
Derived data can be defined by creating selectors - simple functions that receive state and return the derived data.
For complex derived data Redux Toolkit exports a createSelector
function that can memoize data and can be used to improve performance.
Running effects when state changes can be achieved by simply subscribing to the store using store.subscribe
and passing it a function that runs whenever the state changes. If we want to subscribe only to parts of the state, we have to implement additional logic to check if that part of the state has changed. However, Redux is mostly used with React so in practice this kind of logic would most likely be implemented using Reacts own reactivity model.
State updates are simple as Redux Toolkit uses Immer behind the scenes, so we can just .push
values into arrays and everything works. Only thing to remember is that in Redux you have to dispatch
the actions. It's common for new devs to call an action creator without dispatch
and wonder why nothing's working.
Conclusion
Different frameworks and libraries have different approaches solving the same problem.
Picking the best solution is subjective, and I can only offer my point of view, so take this with a grain of salt.
React is great. useEffect
offers lots of control, derived values are simple to declare and there's lots of content online to help you out if you get stuck.
On the other hand, rules of Hooks can be confusing and it's easy to get performance issues or just getting the wrong idea and getting stuck with lots of unnecessary performance optimisations.
Vue is in my opinion the best solution in the list. It's simple, composable, fast, easy to get started with and just makes sense. The only con is that observable state has to be accessed through value
which could be forgotten easily. However it's a small price to pay for all the benefits the framework offers.
Svelte is another slick solution. The $:
and thing = thing
syntax is a bit weird to get used to, but the performance and simplicity of Svelte is pretty great and the framework itself has a bunch of other useful features when it comes to developing UI so it's worth taking a look at.
MobX - for me personally MobX is a far better way to manage state than React Hooks. It doesn't care about the UI layer so it can be used outside the React ecosystem, and it's simple to mutate data. The only downside is that it tracks data access and not the data itself, which can be a source of bugs if you don't keep it in mind.
Solid is a relatively new project, and as such it's not used that much, but it's easy to get started if you're familiar with React. createState
and createSignal
are improvements over React's useState
as it doesn't depend on the order of calls. But the framework is still young so the documentation can be a bit lacking. It looks promising, so we'll see what the future has in store.
Redux has been around for some time now, and it's widely used. This means that there's a lot of content online readily available for developers to pick up. It's not uncommon to hear Redux is hard to learn, and while I somewhat agree with this statement, I think Redux Toolkit makes Redux much more simple and accessible for new devs. It gives you predictable It still needs some boilerplate, but that's no problem for larger projects where it's more important to know where the updates are happening (in the reducers) than having a few lines of code less.
In the end, all approaches have its pros and cons. You have to pick the one that suits your needs the best, and don't be afraid to try new stuff.
Posted on August 4, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.