React Hooks vs Vue 3 Composition API
When React Hooks were introducted in late 2018, they revolutionized the developer experience by introducing a new way to share and reuse "React-ful" logic. Even the creator of Vue aknowledged this and wanted to allow Vue users to leverage the power of this new concept in way that complements Vue''s idomatic API. Today, I want to compare the result of this work in the form of the Vue 3 composition API to React hooks by implementing a sample app. We will examine if Vue''s transparent reactivity based approach really is simpler than React''s immutability based approach and what benefits the React team aims to get in the long term by taking a look at React Concurrent Mode.
History of Hooks
React Hooks where introduced at React Conf in october of 2018. (If you have a bit of time and have not yet watched the introduction talk by Sophie Alpert, Dan Abramov and Ryan Florence, you definitely should.) At first, the community reacted with "a little" scepsis, however, until today, Hooks and Function Components have become the defacto standard way of developing React components for many teams. This blog post will not directly cover the benefits behind and reasons for Hooks. For that, you can check out the official release blog post that collects some resources and goes into more detail.
Nevertheless, I want to emphasize on the killer feature of React Hooks: The reusability of "React-ful" logic. By "React-ful" I mean logic, that uses React's internals like component state and the component lifecycle. Hooks were the first API, that allowed the encapsulation of that kind of logic in a module, without introducing name clashes or opaque APIs where developers would wonder, where a variable came from (which was the case with earlier approaches like mixins or Higher Order Components).
This killer feature even intrigued Evan You, the creator of Vue. On Twitter he noted that Hooks are "objectively better" as a composition mechanism than earlier approaches like mixins, higher order components or render props (source). However, he had a couple of gripes with newly introduced API and set out to find something better, that fits right into the world of Vue. The result of that work is Vue 3's new reactivity system coupled with the new composition API. In case you are not convinced of this new API, I recommend the official RFC page that goes into great detail about the motivation behind the composition API.
In this post, I'd like to compare these two approaches to find differences, commonalities, and allow you to learn something about your own setup, no matter if you are using Vue or React.
Disclamer: I am mainly a React developer, however I admire the simplicity of the composition API. I will try to be as unbiased as possible, but in the end, I still have way more experience with React hooks, which definitely affected my mental model, so that some Vue concepts might be uncommon for me. I will very happily let you correct me if I get anything wrong about the Vue side of things 😊.
Composing a Pokémon team for fun and profit
In order to compare the two approaches, I built a simple app with both libraries. A Pokémon team composer. It is built from 2 simple screens, with 3 relevant components.
On the first screen, you can see your current team, remove Pokémon and use the search bar at the bottom to add new ones to your roster. When you click on the name of one of your team members, you navigate to a details screen, where you can edit the nickname and a comment for the selected Pokémon. The data for this app is persisted in a simple backend, that provides endpoints for fetching your complete team, searching for Pokémon by name, and handling the details for each one.
Simple component state
At first, we want to take a look at handling of simple UI state with hooks and the composition API. For this case, we define state as a piece of data that changes over time and is being reflected by the UI. As an example, we will take a look at the Search component first. In here, we want a textbox and send off fetch requests whenever the content of this input changes. For starters, we will omit the data fetching and focus on the state/UI synchronisation.
useState in React
import React, { useState } from "react";
export function Search() {
const [searchValue, setSearchValue] = useState("");
return (
<input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
/>
);
}
Here you can see the most basic use case of hooks: Whenever we need data that changes over the lifecycle of a component, we define component state by using the useState
hook. We pass in the initial value of this state slot and it returns a tuple
, an array with two values. The first is the current value of the state variable, and the second is the updater function that has to be used to change this value.
This demonstrates the underlying mental modal of React's Function Components: They are written as standard JavaScript functions, that are being called whenever "something" changes. So whenever our state changes (because someone called setSearchValue
) React calls this function again, dropping all variables from the previous execution while only keeping the contents of the component state across calls. This way, the variables in that function block never change and can be defined as const
because they are redeclared with every rerender.
Additionally this means, that state itself is never mutated in React. It's just replaced by calling the state setter before React decides on the best time to rerender the component instance with the new state value. This way, our own code remains completely stateless, wich is a very important nuance that is needed for Concurrent Mode to work optimally.
ref in Vue
<script>
import { ref } from "@vue/composition-api";
export default {
setup(props) {
const search = ref("");
return { search };
},
};
</script>
<template>
<div>
<input v-model="search" />
<input :value="search" @input="search = $event.target.value" />
</div>
</template>
At first glance, this looks pretty similar to the React version: Whenever we need data that changes along the lifetime of a component instance, we can use the ref
function from the new composition API. This function returns an object with one property: value
. And in this field, Vue keeps the current value of the state. The main benefit of this approach is, that Vue can track assignments of this property (by using Proxies
or setters
). This means, that you as a developer don't need to call a specific setter function. You can just assign new values to this value property and even mutate objects or arrays inside of the ref. Additionally, this allows Vue to only call the setup
function once per component instance. This sets up all reactive state computations and returns the resulting values to the template.
In the template part, Vue helps you and unwraps all ref objects, so you don't have to do that yourself, as you can see in lines 15 and 16. For simple cases, binding the ref via v-model is sufficient, however here, I want to demonstrate what happens under the hood: Vue binds the value property of the input element to the contents of the search ref, so whenever the contents of that ref changes (by assignment or mutation), the UI updates. Additionally we add an event listener to the input event, and assign the new value to the ref. This triggers Vue's reactivity system and results in an updated UI aswell.
Derived state
Next, we want to take a look at derived state: Values that can be computed by using the current values of other component state. Examples for that are the validation of forms (which only depend on the current values of the form). To make it a bit simpler, we will just display the length of the entered text, that we can later use to decide if we want to send a search request (because maybe we don't want to send any requests with 2 or less characters).
Just JavaScript™ in React
import React, { useState } from "react";
export function Search() {
const [searchValue, setSearchValue] = useState("");
const length = searchValue.length;
return (
<>
<input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
/>
<span>Length: {length}</span>
</>
);
}
Since React is calling our function whenever state changes, we can use the value inside of the searchValue
const and calculate the length directly in the render function without any React specific logic.
This way, the computed value will always be in sync with the current state and we don't need to manually synchronize states.
computed in Vue
<script>
import { ref, computed } from "@vue/composition-api";
export default {
setup(props) {
const search = ref("");
const length = computed(() => search.value.length);
return { search, length };
},
};
</script>
<template>
<div>
<input v-model="search" />
<input :value="search" @input="search = $event.target.value" />
<span>Length: {{ length }}</span>
</div>
</template>
In Vue on the other hand, we have to approach this task a bit differently. Since the setup function is only called once per component instance, we cannot simply derive some state from a variable since all state variables are encapsulated inside of mutable ref objects. Our goal is, to listen for changes inside of the search ref and do some computations whenever the ref changes. This is exactly what the computed
helper is supposed to do: We pass a getter function, that calculates the current value of this new ref. In there, we can access other reactive objects (props
for example) and Vue will handle the recomputation of this getter function whenever a used dependency changes. computed
then returns a read only ref.
Do note however, that we are accessing the value
property of the search ref. As stated above, Vue needs this wrapper around the value so that it can track dependencies like the computed
length value in line 7.
Here we can nicely see the difference between the mutable world of Vue and the immutable way of thinking in React: With mutability, you have to make sure that no code is mutating the source data without your code noticing it. Vue's reactivity helpers allow that very elegantly: You don't have to manually specify dependencies, you just have to remember to wrap your state computations in the computed
function. That way, you build up these reactive computation chains.
React on the other hand moves all state changes inside itself, so that your code only worries about "snapshots" in time. The Function Components take that snapshot containing all state and generate the new version of the UI including derived states.
One really cool thing about Vue's approach that I want to highlight here: You can use this reactivity system without using Vue for the UI layer. Helpers like ref
or computed
work outside of Vue's setup method completely fine. You can just specify a mutable object with ref
and track changes with computed
like that:
import { ref, computed } from "@vue/composition-api";
const changingCounter = ref(0);
setInterval(() => {
changingCounter.value++;
}, 1000);
const doubled = computed(() => changingCounter.value * 2);
setInterval(() => {
console.log(doubled.value);
}, 500);
In the end, Vue just takes these ref
objects and adds it's own UI-generation logic as a dependant.
So far, we analysed how you can define states, change its values over time and derive state in both React and Vue. This leads us to our next topic:
Side effects
Most of the time, your apps will not only handle local data but also interact with a backend. For that, your components need to react to local state changes, communicate with a remote instance and feed the results back into the state tracking system of your UI library so that your UI can update accordingly.
useEffect in React
Whenever you want to synchronize two sources of data (like UI and server for example) you grab React's useEffect
:
import React, { useState, useEffect } from "react";
import { getInfo } from "../server/apiClient";
export function Search() {
const [searchValue, setSearchValue] = useState("");
const [serverResult, setServerResult] = useState(null);
useEffect(() => {
setServerResult(null);
if (searchValue.length <= 2) return;
getInfo(searchValue).then((result) => setServerResult(result));
}, [searchValue]);
return (
<>
<input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
/>
{serverResult && <img src={serverResult.img} />}
</>
);
}
First we define a second state (again, data that is changing over time) for the server result. Then, we call useEffect
with two arguments: The second is an array of values, that should be watched, called "dependencies" and the first argument is a function that will be executed, whenever at least one of the dependencies changed between renders. Since the change of state does not happen in your code but only inside of React, we have to manually tell React when effects should be rerun.
In this case, we "listen" for changes of the searchValue string, and when this string is longer than 2 characters, we call the getInfo
function and save the result in our state variable to feed the result back into React. We can then use this state in our UI. In this simple case it is quite obvious which variables we have to put into the dependencies array. However, I guess you can see that this might get complex pretty quickly, as you could easily forget a dependency and have your request use stale data. For a more indepth look into useEffect
, I highliy recommend the Complete Guide to useEffect by Dan Abramov.
watchEffect in Vue
In Vue, this code looks a bit simpler:
<script>
import { ref, watchEffect } from "@vue/composition-api";
import { getInfo } from "../server/apiClient";
export default {
setup(props) {
const search = ref("");
const serverResult = ref(null);
watchEffect(() => {
serverResult.value = null;
if (search.value.length <= 2) return;
getInfo(search.value).then((result) => {
serverResult.value = result;
});
});
return { search, serverResult };
},
};
</script>
<template>
<div>
<input v-model="search" />
<img v-if="serverResult" :src="serverResult.img" />
</div>
</template>
The first thing that you might notice is, that we don't have to manually specify the dependencies. Since Vue's reactivity system has dependency tracking built-in, the watchEffect
function can hook into exactly that. Since we accessed the value of the search ref, Vue knows that this function should be called again, whenever this ref changes. When the promise returned by getInfo
resolves, we just write the result into the serverResult
ref, which triggers the reactivity system and results in updated computed values and UI.
On the other hand, you really have to be careful to not forget to add the .value.
property lookup when working with refs. This is one of the places where it really is beneficial to use the improved TypeScript support in Vue 3 so that you cannot forget that.
And of course, watchEffect also works outside of a Vue component:
import { ref, computed, watchEffect } from "@vue/composition-api";
const changingCounter = ref(0);
setInterval(() => {
changingCounter.value++;
}, 1000);
const doubled = computed(() => changingCounter.value * 2);
// Triggered whenever doubled changes
// which changes whenever the counter changes.
watchEffect(() => console.log(doubled.value));
Reusable logic
Until now, everything we did could very easily be achieved with React classes or the traditional Vue Options API. Where the new additions really shine is at writing reusable state-ful logic. For that, we want to write a simple custom hook, that can be used to specify data dependencies in a more declarative way by pulling out the hooks from the previous example.
Custom React Hook
import React, { useState, useEffect } from "react";
import { getInfo } from "../server/apiClient";
export function Search() {
const [searchValue, setSearchValue] = useState("");
const serverResult = useServerData(
getInfo,
searchValue.length > 2 ? searchValue : null
);
return (
<>
<input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
/>
{serverResult && <img src={serverResult.img} />}
</>
);
}
function useServerData(asyncFunction, asyncFunctionArg) {
const [serverResult, setServerResult] = useState(null);
useEffect(() => {
setServerResult(null);
if (!asyncFunctionArg) return;
asyncFunction(asyncFunctionArg).then((result) => setServerResult(result));
}, [asyncFunctionArg, asyncFunction]);
return serverResult;
}
By pulling out the useState
and useEffect
hook, we created a reusable function for data fetching. This can be a very usefull abstraction that you can then use in multiple places of your app. In addition to simple data fetching, you could add caching, error handling, query deduplication and much more. (Alternatively you could just grab the excellent React Query). As a user of this custom hook, you just supply a function that grabs the data from the server and an argument for that function and let the hook handle the orchestration of the async logic (which is kept simple in this case).
Whenever the component instance rerenders, we recall the useServerData
hook with the async function and an argument for that function. Since we passed both values into the useEffect
's dependencies array, the effect will fire whenever we change the function or the argument and we should never get stale data. This way, the hook does not need to make assumptions about which arguments of this functions might change over time.
Custom Vue Composition Function
<script>
import { ref, watchEffect } from "@vue/composition-api";
import { getInfo } from "../server/apiClient";
export default {
setup(props) {
const search = ref("");
const serverResult = useServerData(
getInfo,
computed(() => (search.value.length <= 2 ? null : search.value))
);
return { search, serverResult };
},
};
function useServerData(asyncFunction, asyncFunctionArgRef) {
const serverResult = ref(null);
watchEffect(() => {
serverResult.value = null;
if (!asyncFunctionArgRef.value) return;
asyncFunction(asyncFunctionArgRef.value).then((result) => {
serverResult.value = result;
});
});
return serverResult;
}
</script>
<template>
<div>
<input v-model="search" />
<img v-if="serverResult" :src="serverResult.img" />
</div>
</template>
Conceptually, this is basically the same approach. The only difference is, that in this case the useServerData
function is only called once since the setup
function of the component is only called once per instance. Our composition function has to be able to work with arguments that change over time. In this example, we assumed that the asyncFunction will not need to change over time, while the argument will. This is why we called that one argRef
. In TypeScript we would have typed the signature like that:
function useServerData<ResultType, ArgType>(
fn: (arg: ArgType) => Promise<ResultType>,
arg: Ref<ArgType | null>
): Ref<ResultType | null> {
// ...
}
So that function receives the argument as a ref and returns a ref that contains the result of the server request.
Again, extracting that logic into a custom composition function allows us to later add further features like caching, better error handling or authentication without touching any other code in our app.
Conclusion: Mutable implicit reactivity vs. Immutable explicit changes
In conclusion, both of these APIs allow us developers to build abstractions that enable us to encapsulate stateful logic into smaller composition units: Custom hooks in React and custom composition functions in Vue. The main difference is how they handle changes of state: While Vue allows us to simply mutate data by wrapping it into a ref
, React forces us to keep the data immutable and update it through explicit state setters. On the Vue side of things, the library can then take all used dependencies and efficiently update derived state and UI dependening on the change of some state value. On the other side, React simply recalls all hooks and resulting computations (which you can cache by using useMemo
) and thus does a bit more work in order to allow the use of standard JavaScript values that are not wrapped into any containers.
This is a point that is not quite clear yet for me: When working with mutable data, all libraries and every module that works on your data has to be fine with it being mutated over time. If you can control all parts of the app and use Vue's reactivity system everywhere this will never be a problem, however integrating with third party code might be a bit of a hurdle. I'd be very interested to hear, if some of you ever had any problems with those cases in your apps. To be fair, the same thing can be said in the opposite direction: When working in an immutable world, mutations may break your code. One example that I encountered quite often is the Moment.js date library. It mutates date objects by default which can trip up the dependency arrays in your React application.
So, if the Vue approach is simpler by allowing direct mutations, less error prone by tracking dependencies automatically and more performant by calculating dependencies, we have to ask: Why is React taking that approach? The answer to that question is basically a bet on the future and lies in React's upcoming Concurrent Mode. Take a look at the following gif:
First, as soon as you click on zapdos
, the current screen gets instantly replaced by an empty page with a loading indicator. Because we directly mutated the state (selectedMember
), the UI instantly updates, loading the new screen, that then has to wait for some data.
Of course we could improve the loading indicator or show a skeleton of the next page, however, we still replaced a screen, that could potentially be used during the loading time (for example by switching the selected Pokémon while the first one loaded) with one, that is only showing some loading spinners to the user. Not a great experience.
Compare that to the following gif:
We can see, that the old UI sticks around, until all data is ready. And only then, will the UI instantly switch to the next screen, without any loading indicators or jumping images. This is enabled by using Concurrent Mode:
After we click zapdos
we tell React: Please try to render the app with this given new state and if any data is missing, just wait until it is loaded. React then takes this new state value and tries to render the app with that new state in memory (without showing anything to the user). It does that until it encounters a data dependency (using React Suspense) and waits until that dependency is resolved. During this waiting period, the app is not frozen but remains fully functional: We could display an inline loading spinner, let the user click on other elements etc. This is only possible because state values are never mutated by our code but only replaced inside of React. That way React can concurrently call our stateless functions with different versions of the state without introducing errors. In addition this not only works for waiting periods because of data fetching: In cases where a high priority state update (user typing e.g.) is happening during the preparation of the next screen, we can squeeze the high priority update in first, and then continuing with the low priority expensive update.
As I said, this is basically a bet on the future: Concurrent Mode and Suspense for Data Fetching is still highly experimental. We can play around with it by using the experimental branch of React but the ecosystem is not quite there yet. However, the promise of libraries, that encapsulate data dependencies colocated to the components that need that data is very interesting to me and has me being very intrigued about the future of frontend libraries.
I hope you learned something about the approach and philosphies of React and Vue. If you are interested in the source code of the React and Vue applications you can take a look at the GitHub repository.