@pago/reactive - An Introduction
To get started with @pago/reactive
, you will need to configure Babel, TypeScript or any other compiler to use @pago/reactive
as the jsxImportSource
.
However, we have prepared a CodeSandbox for you so that you can just focus on testing the library,
rather than having to go through setting it up for your environment. When you are ready to integrate it into your setup, you can take a look at the integration examples.
So please open up CodeSandbox to get started with @pago/reactive
.
Or, if you’re already familiar with it, skip to the API Documentation.
A first look at a Reactive Component
When you open up the CodeSandbox, you will find yourself looking at the App.js
with a component similar to this:
export default function App() {
const count = ref(0);
effect(() => {
console.log(`The count is now ${count.current}!`);
});
return () => (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
<p>Your current count is {count.current}</p>
<div>
<button type="button" onClick={() => count.current++}>
Increment
</button>
<button type="button" onClick={() => count.current--}>
Decrement
</button>
</div>
</div>
);
}
We will want to replace that component with a simple standard React Component so that we can work ourselves towards that version. That could look something like this:
export default function App() {
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
<p>Your current count is {0}</p>
<div>
<button type="button" onClick={() => {}}>
Increment
</button>
<button type="button" onClick={() => {}}>
Decrement
</button>
</div>
</div>
);
}
This a regular old React Function Component. The very curious people will recognize that we are still using @pago/reactive
to render JSX. That’s fine, @pago/reactive
is fully compatible with standard React Components and does not interfere with their execution.
From React to Reactive
In our first step towards leveraging the power of @pago/reactive
we want to convert our standard React Component into a Reactive Component.
The one thing we need to do to make that happen is to return a render
function instead of the JSX.
export default function App() {
return () => <div className="App">{/* same as before */}</div>;
}
This change converts our standard React Component into a Reactive Component. And it already yields a benefit: Improved performance.
By converting a React Component into a Reactive Component, we have optimized the rendering of the component in the same way that React.memo
optimizes your React Components: It will always return the same Virtual DOM tree unless any given property that you are actually using within the render
function changes.
In many ways this is actually even better than the optimization offered by React.memo
because it only tracks properties that you are actually using. If somebody passes in a new property that your component doesn’t even accept, that will not cause your component to bail out from optimization.
The four phases of a Reactive Component Lifecycle
The @pago/reactive
library has been build with Reacts Concurrent Mode in mind and makes it easy for your code to fit into that execution model.
Because of that, a Reactive Component has a well defined Lifecycle that consists of four stages:
- Creation Phase
- Render Phase
- Effects Phase
- Teardown Phase
Let’s look at an example of those phases and where they live in our Reactive Component:
import { effect } from '@pago/reactive';
export default function App() {
// PHASE 1: Creation Phase
// Any code placed here will only execute once during the component creation
effect(onInvalidate => {
// PHASE 3: Effects Phase
// An effect will run after the component has been commited (i.e. rendered to DOM nodes).
// It will also be invoked whenever any tracked state changes (more details later).
onInvalidate(() => {
// PHASE 4: Teardown
// This callback is invoked when the component is unmounted
// or before the effect is run again due to tracked state changes
});
});
return () => (
{/* PHASE 2: Render Phase
Code placed here will run whenever the component is rendered. */}
<div className="App">
{/* same as before */}
</div>
);
}
This clear separation of phases makes it easy to write code that conforms with the fundamental React principle of side-effects free rendering.
It allows a Reactive Component to optimize itself and avoid running unnecessary code. But more importantly, it allows you to write simple and
straightforward code for your component without having to think about a clever combination of useEffect
, useRef
and useState
that might
yield the desired behaviour.
You might be wondering why effect
passes in an onInvalidate
function rather than expecting you to return the teardown function like
Reacts useEffect
does. This way, you can make your effect async
and leverage async
/ await
in a straightforward way without requiring
any tricks on your side.
Tracked State
We have already hinted that properties passed to a Reactive Component are tracked
and that a change to them will cause the component to render again.
But @pago/reactive
wouldn’t be very useful if that was all it offered. Instead, it offers ways to create your own tracked
state through the ref
and the reactive
functions.
When using the reactive
function, we can turn any object into a tracked
object with minimal fuss. Let’s look at what that might look like:
export default function App() {
const state = reactive({
count: 0,
});
return () => (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
<p>Your current count is {state.count}</p>
<div>
<button type="button" onClick={() => state.count++}>
Increment
</button>
<button type="button" onClick={() => state.count--}>
Decrement
</button>
</div>
</div>
);
}
We can just access the count
property of the tracked
object state
, reading from it and mutating it however we want.
When the user clicks on either the “Increment” or the “Decrement” buttons, we mutate the state
object, causing the component to be rendered again.
The other type of state is something that has been part of React for a long time: a ref
. It offers the exact same shape as a ref
created by useRef
.
However, its value is tracked
and changes to it will trigger effects and rendering when and where necessary.
export default function App() {
const state = reactive({
count: 0,
});
const h1 = ref();
effect(() => {
h1.current.style.color = 'blue';
});
return () => (
<div className="App">
<h1 ref={h1}>Hello CodeSandbox</h1>
{/* same as before */}
</div>
);
}
Whenever the h1
ref changes its current value, the effect will be triggered.
Besides for tracking DOM elements, we can also use ref
to manage our state if we want to store a single value, rather than a full object.
function createCounter() {
const count = ref(0);
return {
get count() {
return count.current;
},
increment() {
count.current++;
},
decrement() {
count.current--;
},
};
}
We could now use this function in any of our Reactive Components and it would just work.
Beware: Destructuring
When you use destructuring on a reactive
object, it will loose its reactivity and its values won’t be tracked anymore. Thus, you need to first
convert the object into a RefContainer
by using the toRefs
functions.
function Timer(props) {
const { step, delay } = toRefs(props);
const count = ref(0);
effect(onInvalidate => {
const t = setInterval(() => {
count.current += step.current;
}, delay.current);
onInvalidate(() => clearInterval(t));
});
return () => <span>Timer: {count.current}</span>;
}
Global Tracked State
When you are building a client side only application without server side rendering, you can deal with your
global state needs by using a global tracked
state variable.
Both ref
and reactive
can be used for creating them and using and mutating them works as expected.
const globalCount = ref(0);
export default function App() {
return () => (
<div className="App">
<h1>Hello CodeSandbox</h1>
<h2>Start editing to see some magic happen!</h2>
<p>Your current count is {globalCount.current}</p>
<div>
<button type="button" onClick={() => globalCount.current++}>
Increment
</button>
<button type="button" onClick={() => globalCount.current--}>
Decrement
</button>
</div>
</div>
);
}
Every component that uses globalCount
will now be kept in sync automatically.
State Management with React.Context
When Server Side Rendering is a concern for you or you would like to avoid global state for reasons of testability of your code,
you might want to leverage the React.Context API instead. @pago/reactive
makes it very easy to use React Context for state management
in your application by making it easy to avoid bugs and performance issues.
Let’s go back to our getCounter
function that we’ve defined previously and let’s put an instance of it into a Context so that can be used elsewhere:
import { ref, inject } from '@pago/reactive';
import { createContext } from 'react';
// a fancy counter model
function createCounter() {
const count = ref(0);
return {
get count() {
return count.current;
},
increment() {
count.current++;
},
decrement() {
count.current--;
},
};
}
// creating the React Context to store it
const CounterContext = createContext();
// A provider component that makes the context available
function CounterStateProvider(props) {
const model = createCounter();
return () => (
<CounterContext.Provider value={model}>
{props.children}
</CounterContext.Provider>
);
}
function Counter() {
const model = inject(CounterContext);
return () => <span>The current count is {model.count}</span>;
}
Because of the Lifecycle Phases of a Reactive Component, the value stored within the context will always be the same, avoiding unnecessary renderings. However, whenever the state of the model changes, all components and effects using it will be triggered automatically.
To gain access to our Context within a Reactive Component, we use the inject
function provided by @pago/reactive
to inject it into our component.
Together with the various utility functions in @pago/reactive
, such as derived
, readonly
or watchEffect
, you might find less of a need to
reach for libraries like MobX or Recoil in your application.
Compatibility with Hooks
There are many useful React Hooks out there that you might want to use in your application. Maybe you are not even writing a new one but have to integrate @pago/reactive
into your current codebase that is full of existing Hooks.
As we’ve discovered right at the beginning, React and Reactive Components can live next to each other without any problems. But can they interact? Can you leverage existing Hooks? Of course you can!
Using existing Hooks in Reactive Components
Let’s assume that you have a wonderful useScreenSize
Hook that you would like to use within your Reactive Component.
All you’ll need to do is to pass it to fromHook
:
import { fromHook } from '@pago/reactive';
import { useScreenSize } from 'somewhere';
function ScreenSizePrinter() {
const screenSize = fromHook(useScreenSize);
return () => <p>The current screen size is {screenSize.current}</p>;
}
@pago/reactive
will automatically execute the Hook on every rendering of the Reactive Component, giving it a chance to modify
the screenSize
ref
value and thus potentially causing a rerendering. You can pass any kind of function to fromHook
and can
use all existing React Hooks to do its work. It does not have to result in a new value.
function Timer() {
const timer = fromHook(function useTimer() {
const [timer, setTimer] = useState(0);
useEffect(() => {
const t = setInterval(() => {
setTimer(current => current + 1);
}, 1000);
return () => clearInterval(t);
}, []);
return timer;
});
return () => <span>Timer: {timer.current}</span>;
}
We pass a named function expression useTimer
to fromHook
to signal to eslint that we are within a React Hook and that it should apply
all of its usual logic to the function scope. As mentioned before, the function you pass to fromHook
does not have to return a value.
function Timer() {
const timer = ref(0);
fromHook(function useTimer() {
useEffect(() => {
const t = setInterval(() => {
timer.current++;
}, 1000);
return () => clearInterval(t);
}, []);
});
return () => <span>Timer: {timer.current}</span>;
}
@pago/reactive
offers another automatic performance improvement over React Components when using Hooks:
In React, when a Hook signals that it needs to be executed again, the entire component will re-render. In a Reactive Component,
all registered Hooks will be triggered but if that doesn’t result in an actual change of the state that is tracked
by
the render
function, then no rendering will happen and the old Virtual DOM tree will be reused.
Using tracked ref objects in a Hook
The example above, compared to its previous purely Reactive Component versions, no longer accepts properties to
control the delay or the incrementation step. The function passed to fromHook
is not tracked by default. Instead,
you are asked to leverage the useRefValue
Hook to mark a value as tracked within your custom Hook.
import { toRefs, useRefValue } from '@pago/reactive';
import { useEffect, useState } from 'react';
function Timer(props) {
const { step, delay } = toRefs(props);
const timer = fromHook(function useTimer() {
const [timer, setTimer] = useState(0);
const currentStep = useRefValue(step);
const currentDelay = useRefValue(delay);
useEffect(() => {
const t = setInterval(() => {
setTimer(current => current + currentStep);
}, currentDelay);
return () => clearInterval(t);
}, [currentStep, currentDelay]);
return timer;
});
return () => <span>Timer: {timer.current}</span>;
}
By using useRefValue
to extract a value from a ref
, we mark it as read. Thus, any changes to it will cause the component using the Hook to
be invalidated and updated.
The useRefValue
function can be used in any React Function Component or React Hook
and enables React applications to manage their state through @pago/reactive
.
Next Steps
If you’ve enjoying reading this introduction, please give it a try in CodeSandbox or look through the examples to see how to setup a Next.js project. More examples will follow over time.
This project is still early on and bugs and issues should be expected. When you encounter anything strange or counter-intuitive, please open an report the issue on GitHub. That will help us to make the library better and to reach production quality.
You can also take a look at the API Documentation to learn more about the API offered by @pago/reactive
.