Comparing React Hooks with Vue Composition API
What are their similarities and differences?
Vue recently presented the Composition API RFC, a new API for writing Vue components inspired by React Hooks but with some interesting differences that I will discuss in this post. This RFC started with a previous version called Function-based Component API that received lots of criticism from certain part of the community, based on the fear of Vue starting to be more complicated and less like the simple library that people liked in the first place.
The Vue core team addressed the confusion around the first RFC and this new one presented some interesting adjustments and provided further insights on the motivations behind the proposed changes. If you are interested in giving some feedback to the Vue core team about the new proposal you can participate in the discussion on GitHub.
Note: The Vue Composition API is a work in progress and is subject to future changes. Nothing regarding the Vue Composition API is 100% sure until Vue 3.0 arrives.
React Hooks allow you to "hook into" React functionalities like the component state and side effects handling. Hooks can only be used inside function components and allows us to bring state, side-effects handling and much more to our components without the need to create a class for them. The community fell in love with them immediately since their introduction in 2018.
The adoption strategy prepared by the React core team was to not deprecate Class Components so you could update the React version, start trying Hooks in new components and keep your existing components without any modification.
So, let's get started studying the different aspects of React Hooks and Vue Composition API and remark certain differences that we might find along the way ⏯
React Hooks
Example:
import React, { useState, useEffect } from "react";
const NoteForm = ({ onNoteSent }) => {
const [currentNote, setCurrentNote] = useState("");
useEffect(() => {
console.log(`Current note: ${currentNote}`);
});
return (
<form
onSubmit={(e) => {
onNoteSent(currentNote);
setCurrentNote("");
e.preventDefault();
}}
>
<label>
<span>Note: </span>
<input
value={currentNote}
onChange={(e) => {
const val = e.target.value && e.target.value.toUpperCase()[0];
const validNotes = ["A", "B", "C", "D", "E", "F", "G"];
setCurrentNote(validNotes.includes(val) ? val : "");
}}
/>
</label>
<button type="submit">Send</button>
</form>
);
};
useState
and useEffect
are some examples of React Hooks. They allow it to add state and run side-effect in function components. There are additional hooks that we will see later and you can even create custom ones. This opens new possibilities for code reusability and extensibility.
Vue Composition API
Example:
<template>
<form @submit="handleSubmit">
<label>
<span>Note:</span>
<input v-model="currentNote" @input="handleNoteInput" />
</label>
<button type="submit">Send</button>
</form>
</template>
<script>
import { ref, watch } from "vue";
export default {
props: ["divRef"],
setup(props, context) {
const currentNote = ref("");
const handleNoteInput = (e) => {
const val = e.target.value && e.target.value.toUpperCase()[0];
const validNotes = ["A", "B", "C", "D", "E", "F", "G"];
currentNote.value = validNotes.includes(val) ? val : "";
};
const handleSubmit = (e) => {
context.emit("note-sent", currentNote.value);
currentNote.value = "";
e.preventDefault();
};
return {
currentNote,
handleNoteInput,
handleSubmit,
};
},
};
</script>
Vue Composition API is centered around a new component option called setup
. It provides a new set of functions for adding state, computed properties, watchers and lifecycle hooks to our Vue components.
This new API won't make the original API (now referred to as the "Options-based API") disappear. The current iteration of the proposal allows developers to even combine both components APIs together.
Note: you can try this in Vue 2.x using the @vue/composition-api plugin.
Execution of the code
The setup
function of the Vue Composition API is called after the beforeCreate
hook (in Vue, a "hook" is a lifecycle method) and before the created
hook. This is one of the first differences we can identify between React Hooks and Vue Composition API, React hooks run each time the component renders while the Vue setup
function only runs once while creating the component. Because React Hooks can run multiple times, there are certain rules the render function must follow, one of them being:
Don’t call Hooks inside loops, conditions, or nested functions.
Here is a code example straight from React docs that demonstrates this:
function Form() {
// 1. Use the name state variable
const [name, setName] = useState('Mary');
// 2. Use an effect for persisting the form
if (name !== '') {
useEffect(function persistForm() {
localStorage.setItem('formData', name);
});
}
// 3. Use the surname state variable
const [surname, setSurname] = useState('Poppins');
// 4. Use an effect for updating the title
useEffect(function updateTitle() {
document.title = `${name} ${surname}`;
});
// ...
}
React internally keeps track of all the hooks we are using in our component. In this example, we are using four hooks. Notice how the first useEffect
invocation is done conditionally, and since on the first render the name
state variable will be assigned the default value of 'Mary'
the condition will be evaluated to true
and React will know that it needs to keep track of all of these four hooks in order. But what happens if on another render name
is empty? Well, in that case, React won't know what to return on the second useState
hook call 😱. To avoid this and other issues, there is an ESLint plugin that is strongly recommended when working with React Hooks and is included by default with Create React App.
What if we just want to run the effect if name
is not empty then? We can simply move it inside the useEffect
callback:
useEffect(function persistForm() {
if (name !== "") {
localStorage.setItem("formData", name);
}
});
Going back to Vue, something equivalent to the previous example would be this:
export default {
setup() {
// 1. Use the name state variable
const name = ref("Mary");
// 2. Use a watcher for persisting the form
if(name.value !== '') {
watch(function persistForm() => {
localStorage.setItem('formData', name.value);
});
}
// 3. Use the surname state variable
const surname = ref("Poppins");
// 4. Use a watcher for updating the title
watch(function updateTitle() {
document.title = `${name.value} ${surname.value}`;
});
}
}
Since the setup
method will only run once, we can make use of the different functions that are part of the Composition API (reactive
, ref
, computed
, watch
, lifecycle hooks, etc.) as part of loops or conditional statements. However, the if
statement will also only run once, so it won't react to changes to name
unless we include it inside of the watch
callback:
watch(function persistForm() => {
if(name.value !== '') {
localStorage.setItem('formData', name.value);
}
});
Declaring state
useState
is the main way to declare state with React Hooks. You can pass the initial value as an argument to the call and if the computation of the initial state is expensive you can express it as a function that will only be executed during the initial render.
const [name, setName] = useState("Mary");
const [age, setAge] = useState(25);
console.log(`${name} is ${age} years old.`);
It returns an array with the state as the first element and a setter function in second place. Usually, you use Array destructuring to grab them.
A handy alternative is using useReducer
that accepts a Redux-like reducer and an initial state in its more usual variant. There's also a variant with lazy initialization:
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case "increment":
return { count: state.count + 1 };
case "decrement":
return { count: state.count - 1 };
default:
throw new Error();
}
}
const [state, dispatch] = useReducer(reducer, initialState);
You can then use the dispatch
function like dispatch({type: 'increment'});
.
Vue works differently due to its reactive nature. You have two main functions to declare state: ref
and reactive
.
ref
returns a reactive object where the inner value it contains is accessed by its value
property. You can use ref
with primitive values or objects and in the case of objects, they are made deeply reactive.
const name = ref("Mary");
const age = ref(25);
watch(() => {
console.log(`${name.value} is ${age.value} years old.`);
});
reactive
on the other hand can only take an object as its input and returns a reactive proxy of it. Note that the reactivity affects all nested properties.
const state = reactive({
name: "Mary",
age: 25,
});
watch(() => {
console.log(`${state.name} is ${state.age} years old.`);
});
The RFC has a whole section comparing ref
and reactive
. It ends up with a summary of possible approaches for using them:
Use ref and reactive just like how you'd declare primitive type variables and object variables in normal JavaScript. It is recommended to use a type system with IDE support when using this style.
Use reactive whenever you can, and remember to use toRefs when returning reactive objects from composition functions. This reduces the mental overhead of refs but does not eliminate the need to be familiar with the concept.
Something to keep in mind when using ref
is that you need to remember to access the contained value by the value
property of the ref (except in templates, where Vue allows you to omit it). Whereas with reactive
you will lose reactivity if you destructure the object. So you need to have a reference to the object and access the state properties you defined through it.
The Composition API provides two helper functions for dealing with refs and reactive objects. isRef
can be used to conditionally grab the value
property if needed (e.g. isRef(myVar) ? myVar.value : myVar
) and toRefs
converts a reactive object to a plain object where all of its properties are automatically transform to refs. Particularly useful when returning from custom composition functions (and thus allowing destructuring to be used from the caller side and keep reactivity).
function useFeatureX() {
const state = reactive({
foo: 1,
bar: 2,
});
return toRefs(state);
}
const { foo, bar } = useFeatureX();
How to track dependencies
The useEffect
Hook in React allows us to run certain side effect (like making a subscription, data fetching or using Web APIs such as storage) after each render and to optionally run some cleanup before the next execution of the callback or when the component will unmount. By default, all useEffect
registered functions will run after each render but we can define the actual state and props dependencies so that React skips the execution of a certain useEffect
hook if the relevant dependencies haven't changed (e.g. a render was made because of another piece of state update). Going back to our previous Form
example we can pass an array of dependencies as the second argument of the useEffect
hook:
function Form() {
const [name, setName] = useState('Mary');
const [surname, setSurname] = useState('Poppins');
useEffect(function persistForm() {
localStorage.setItem('formData', name);
}, [name]);
// ...
}
This way, only when name
changes we will update the localStorage
. A common source of bugs with React Hooks is forgetting to exhaustively declare all of our dependencies in the dependencies array. You can end up with your useEffect
callback not being updated with the latest dependencies and referring instead to stale values from previous renders. Fortunately, the eslint-plugin-react-hooks
includes a lint rule that warns about missing dependencies.
useCallback
and useMemo
also use an array of dependencies argument to decide if they should return the same memoized version of the callback or value respectively than the last execution or not.
In the case of Vue Composition API, we can use the watch
function to perform side effects in response to props or state changes. Thanks to the reactivity system of Vue the dependencies will be automatically tracked and the registered function will be called reactively when the dependencies change. Going back to our example:
export default {
setup() {
const name = ref("Mary");
const lastName = ref("Poppins");
watch(function persistForm() => {
localStorage.setItem('formData', name.value);
});
}
}
After the first time our watcher runs, name
will be tracked as a dependency and when its value changes at a later time, the watcher will run again.
Access to the lifecycle of the component
Hooks represent a complete switch of the mental model when dealing with lifecycle, side effects and state management of your React component. Ryan Florence, an active member of the React community, expressed that there is a mental shift to be made from class components into hooks, and as the React docs point out:
If you’re familiar with React class lifecycle methods, you can think of
useEffect
Hook ascomponentDidMount
,componentDidUpdate
, andcomponentWillUnmount
combined.
It is possible, however, to control when useEffect
will run and bringing us closer to the mental model of running side effects in lifecycles:
useEffect(() => {
console.log("This will only run after initial render.");
return () => {
console.log("This will only run when component will unmount.");
};
}, []);
But once again, it's more idiomatic when using React Hooks to stop thinking in terms of lifecycle methods but to think about what state our effects depend on. By the way, Rich Harris, the creator of Svelte published some insightful slides he presented at an NYC React meetup where he explores the compromises React is making to enable new features in the future (e.g. concurrent mode) and how Svelte differs from that. It will help you understand the shift from thinking in components with lifecycle where side effects happen to side effects being part of the render itself. Sebastian Markbåge from the React core team, further expands here on the direction React is taking and compromises with reactivity systems like Svelte or Vue.
Vue Component API in the other hand, still gives us access to lifecycle hooks (the equivalent name that lifecycle methods get in the Vue world) with onMounted
, onUpdated
and onBeforeUnmount
, etc:
setup() {
onMounted(() => {
console.log(`This will only run after initial render.`);
});
onBeforeUnmount(() => {
console.log(`This will only run when component will unmount.`);
});
}
So in the case of Vue the mental model shift is rather one of stop thinking of organizing the code by which component options (data
, computed
, watch
, methods
, lifecycle hooks, etc.) they belong to, towards one where you can have different functions each dealing with a specific feature. The RFC includes a thorough example and comparison of organizing by options vs. organizing by logical concerns. React Hooks also have this benefit and is something that was also well received by the community from the ground up.
Custom code
One aspect that the React Team wanted to focus with Hooks is to provide developers with a nicer way of writing reusable code than previous alternatives adopted by the community, like Higher-Order Components or Render Props. Custom Hooks are the answer they came up with.
Custom Hooks are just regular JavaScript functions that make use of React Hooks inside of it. One convention they follow is that their name should start with use
so that people can tell at a glance that it is meant to be used as a hook.
export function useDebugState(label, initialValue) {
const [value, setValue] = useState(initialValue);
useEffect(() => {
console.log(`${label}: `, value);
}, [label, value]);
return [value, setValue];
}
This tiny example Custom Hook can be used as a replacement of useState
while logging to the console when the value changes:
const [name, setName] = useDebugState("Name", "Mary");
In Vue, Composition Functions are the equivalent of Hooks with the same set of logic extraction and reusability goals. As a matter of fact, we can have a similar useDebugState
composition function in Vue:
export function useDebugState(label, initialValue) {
const state = ref(initialValue);
watch(() => {
console.log(`${label}: `, state.value);
});
return state;
}
// elsewhere:
const name = useDebugState("Name", "Mary");
Note: By convention composition functions also use use
as a prefix like React Hooks to make it clear it's a composition function and that it belongs in setup
Refs
Both React useRef
and Vue ref
allow you to reference a child component (in the case of React a Class Component or component wrapped with React.forwardRef
) or DOM element that you attach it to.
React:
const MyComponent = () => {
const divRef = useRef(null);
useEffect(() => {
console.log("div: ", divRef.current)
}, [divRef]);
return (
<div ref={divRef}>
<p>My div</p>
</div>
)
}
Vue:
export default {
setup() {
const divRef = ref(null);
onMounted(() => {
console.log("div: ", divRef.value);
});
return () => (
<div ref={divRef}>
<p>My div</p>
</div>
)
}
}
Note that in the case of Vue, allocating template refs with JSX on the render function returned by setup()
is not supported on the @vue/composition-api
Vue 2.x plugin, but the above syntax will be valid in Vue 3.0 according to the current RFC.
The useRef
React Hook is not only useful for getting access to DOM elements though. You can use it for any kind of mutable value that you want to keep between renders but aren't part of your state (and thus won't trigger re-renders when they are mutated). You can think about them as "instance variables" that you would have in a Class Component. Here is an example:
const timerRef = useRef(null);
useEffect(() => {
timerRef.current = setInterval(() => {
setSecondsPassed((prevSecond) => prevSecond + 1);
}, 1000);
return () => {
clearInterval(timerRef.current);
};
}, []);
return (
<button
onClick={() => {
clearInterval(timerRef.current);
}}
>
Stop timer
</button>
);
And in the Vue Composition API, as we saw in almost all of our examples earlier in this post, ref
can be used to define reactive state. Template refs and reactive refs are unified when using the Composition API.
Additional functions
Since React Hooks run on each render there's no need to an equivalent to the computed
function from Vue. You are free to declare a variable that contains a value based on state or props and it will point to the latest value on each render:
const [name, setName] = useState("Mary");
const [age, setAge] = useState(25);
const description = `${name} is ${age} years old`;
In the case of Vue, the setup
function only runs one. Hence the need to define computed properties, that should observe changes to certain state and update accordingly (but only when one of their dependencies change):
const name = ref("Mary");
const age = ref(25);
const description = computed(() => `${name.value} is ${age.value} years old`);
As usual, remember that refs are containers and the value is accessed through the value
property ;)
But what happens if calculating a value is expensive? you wouldn't want to compute it every time your component renders. React includes the useMemo
hook for that:
function fibNaive(n) {
if (n <= 1) return n;
return fibNaive(n - 1) + fibNaive(n - 2);
}
const Fibonacci = () => {
const [nth, setNth] = useState(1);
const nthFibonacci = useMemo(() => fibNaive(nth), [nth]);
return (
<section>
<label>
Number:
<input
type="number"
value={nth}
onChange={e => setNth(e.target.value)}
/>
</label>
<p>nth Fibonacci number: {nthFibonacci}</p>
</section>
);
};
useMemo
also expects a dependencies array to know when it should compute a new value. React advice you to use useMemo
as a performance optimization and not as a guarantee that the value will remain memoized until a change in any dependency occurs.
As a side note: Kent C. Dodds has a really nice article explaining many situations where useMemo
and useCallback
aren't necessary.
Vue's computed
perform automatic dependency tracking so it doesn't need a dependencies array.
useCallback
is similar to useMemo
but is used to memoize callback functions. As a matter of fact useCallback(fn, deps)
is equivalent to useMemo(() => fn, deps)
. The ideal use case of it is when we need to maintain referential equality between renders, e.g. we are passing the callback to an optimized child component that was defined with React.memo
and we want to avoid it to re-render unnecessarily. Due to the nature of the Vue Composition API, there is no equivalent to useCallback
. Any callback in the setup
function will only be defined once.
Context and provide/inject
React has the useContext
hook as a new way to read the current value for the specified context. The value to return is determined, as usual, as the value
prop of the closest <MyContext.Provider>
component in the ancestors tree. It's equivalent to static contextType = MyContext
in a class or the <MyContext.Consumer>
component.
// context object
const ThemeContext = React.createContext('light');
// provider
<ThemeContext.Provider value="dark">
// consumer
const theme = useContext(ThemeContext);
Vue has a similar API called provide/inject. It exists in Vue 2.x as components options but a pair of provide
and inject
functions are added as part of the Composition API to be used inside a setup
function:
// key to provide
const ThemeSymbol = Symbol();
// provider
provide(ThemeSymbol, ref("dark"));
// consumer
const value = inject(ThemeSymbol);
Note that if you want to retain reactivity you must explicitly provide a ref
/reactive
as the value.
Exposing values to render context
In the case of React since all of your Hooks code is on the component definition and you return the React elements that you would like to render in the same function, you have full access to any value in the scope as you would in any JavaScript code:
const Fibonacci = () => {
const [nth, setNth] = useState(1);
const nthFibonacci = useMemo(() => fibNaive(nth), [nth]);
return (
<section>
<label>
Number:
<input
type="number"
value={nth}
onChange={(e) => setNth(e.target.value)}
/>
</label>
<p>nth Fibonacci number: {nthFibonacci}</p>
</section>
);
};
In the case of Vue if you have your template defined in the template
or render
options or if you are using Single File Components you need to return an object from the setup
function containing every value that you want to expose to the template. Your return statement can potentially end up being verbose since you could potentially want to expose many values and this is a point to be aware of as mentioned in the RFC:
<template>
<section>
<label>
Number:
<input
type="number"
v-model="nth"
/>
</label>
<p>nth Fibonacci number: {{nthFibonacci}}</p>
</section>
</template>
<script>
export default {
setup() {
const nth = ref(1);
const nthFibonacci = computed(() => fibNaive(nth.value));
return { nth, nthFibonacci };
}
};
</script>
}
One way to achieve the same behavior present in React is returning a render function from the setup
option itself:
export default {
setup() {
const nth = ref(1);
const nthFibonacci = computed(() => fibNaive(nth.value));
return () => (
<section>
<label>
Number:
<input type="number" vModel={nth} />
</label>
<p>nth Fibonacci number: {nthFibonacci}</p>
</section>
);
},
};
However, templates are way more popular in Vue so exposing an object with values is certainly going to be something that you will encounter a lot with Vue Composition API.
Conclusion
These are exciting times for both frameworks. Since the introduction of React Hooks in 2018, the community has built amazing things on top of them and the extensibility of Custom Hooks allowed for many open source contributions that can be easily added to our projects. Vue is taking inspiration from React Hooks and adapting them in a way that feels nice for the framework and serves as an example of how all of these different technologies can embrace change and share ideas and solutions. I can't wait for Vue 3 to arrive and see the possibilities that it unlocks.
Thank you for reading and keep building awesome stuff 🚀