loading...

Preact Composition API

porfirioribeiro profile image Porfirio ・8 min read

My name is Porfírio and I work at Agroop for 3 years, building an App using React.
I'm always curious with new technologies and ways to do things, so I started testing React Hooks since the first day it was announced and started using them in production right after the first release.

So when I heard all the fuzz around an RFC in Vuejs because of a new API I started looking at it and try to understand what it was.
After the initial fuzz, they get to set a quite interesting API

At the time I was also reading Preact source, the advantage of having a small library, is that it is possible to read the code and understand most of it.
Infact Preact source for Hooks, had helped me understand how Hooks work, in a way I was not able to do when I tried to read React source. And I found out this interesting API in Preact that let you hook into the rendering process and thats what they use to introduce Hooks into Preact as a separate bundle without increasing Preact size

Has I enjoyed so much the new Vue API and I was messing with Preact I decided to create a proof of concept of implementing the new Vue Composition API on Preact.
You can find it here: https://github.com/porfirioribeiro/preact/blob/composition-api/composition/src/index.js

Meanwhile I created a WIP PR on preact repo: https://github.com/preactjs/preact/pull/1923

Of course that there are diferences from the Vue api, as both libraries handle things differently.

Comparing with Vue Composition API

https://vue-composition-api-rfc.netlify.com

Preact Composition API is heavily inspired by Vue, so it's API tries to mimic Vue API but it's not exactly the same, due to some limitations or by design.

createComponent / setup

Vue uses createComponent accepts an object that includes setup, that is Vue way to define components, with objects. In fact,createComponent does nothing, mostly helps with typing.
In Preact createComponent accepts a function that returns a function component. It does not do much in Preact either, it just marks that function as a Composition function so Preact can handle it diferently.

export const MyComp = createComponent(() => {
    // composition code
    //
    // return function component
    return ({ message }) => <div>{message}</div>;
});

reactive

reactive wraps an object in a proxy so that every time the object is changed the component is updated, working as a state holder.

export const MyComp = createComponent(() => {
    const obj = reactive({ count: 0 });

    function increase() {
        obj.count++;
    }

    return ({ message }) => (
        <div onClick={increase}>
            {message} {obj.count}
        </div>
    );
});

ref

ref is also a state holder, mostly it wraps one value, we need this as in JavaScript natives are passed by value, not reference.
When theRef.value is changed, the component is updated.
The implementation of ref is more simple than reactive as it uses an object with getters/setters.

export const MyComp = createComponent(() => {
    const count = ref(0);

    function increase() {
        count.value++;
    }

    return ({ message }) => (
        <div onClick={increase}>
            {message} {count.value}
        </div>
    );
});

isRef

isRef returns if a object is a ref
unwrapRef try to unwrap the ref

const value = isRef(foo) ? foo.value : foo; //same as
const value = unwrapRef(foo);

toRefs

toRefs is not implemented yet as the design of the API in Preact is different of the Vue one, didn't found a good use for it, yet.

computed

computed is not implemented as is, it's mostly joined with watch as the Preact life cycle works a little different from Vue

watch

watch in Preact is a bit different from watch in Vue, due to the differences from Preact and Vue, and also some API design to support other Preact features like Context
Because of that nature, we have 2 functions alike: watch and effect
watch runs before render and can return a ref with the result of it's execution
effect is run after update, as a side effect

//un-exhausted example of what watch can do!
const countGetter = props => props.countProp;

export const MyComp = createComponent(() => {
    const countRef = ref(0);
    const reactiveObj = reactive({ count: 0 });

    const memoizedComputedValue = watch(
        [countRef, reactiveObj, countGetter],
        // this will be computed when any of those 3 dependencies are updated
        // works as computing and memoization
        ([count, obj, countFromProps]) => count * obj * countFromProps
    );

    effect(
        memoizedComputedValue,
        value => (document.title = `computed [${value}]`)
    );

    function increase() {
        countRef.value++;
    }

    return ({ message }) => (
        <div onClick={increase}>
            {message} {memoizedComputedValue.value}
        </div>
    );
});

lifecycle-hooks

Only some lifecycle hooks are implemented, some not implemented yet, others will not be implemented as it does not make sense or can't be implemented in Preact

  • onMounted Callback to call after the component mounts on DOM
  • onUnmounted Callback to call right before the component is removed from DOM
  • effect cannot be considered a lifecycle, but can be used to achieve the same as onUpdated in Vue, tracking the needed dependencies.

provide-inject

provide and inject is not implemented as Preact already have a Context API, probably it can be implemented later.

We can achieve inject like feature by passing a Context as src on watch or effect, making the component subscribe to the closest Provider of that Context

export const MyComp = createComponent(() => {
    const userCtx = watch(UserContext);

    return ({ message }) => (
        <div>
            {message} {userCtx.value.name}
        </div>
    );
});

Comparing with (P)React Hooks

https://reactjs.org/docs/hooks-reference.html

At the first look we might find React hooks and Preact Composition API(PCApi) alike, but there is a HUGE difference between them.

The function passed to createComponent when we call the composition functions is only executed once during the component lifecycle, the returned function component is executed at each update.
And in React the hooks are always called and (most of it) redefined in each render, Vue has a good explanation of the differences

This has to bring a mind shift, in hooks you can deal with simple variables but have to deal with code re-declaration and memoizing of values and callbacks to avoid children re-renders.

useState

useState is used in React as a state holder, in PCApi ref or reactive can be used, depending on the need of holding a single value or multiple value object

// (P)React hooks
const Counter = ({ initialCount }) => {
    // redeclared and rerun on each render
    const [count, setCount] = useState(initialCount);
    const reset = () => setCount(initialCount);
    const increment = () => setCount(prevCount => prevCount + 1);
    const decrement = () => setCount(prevCount => prevCount - 1);
    return (
        <>
            Count: {count}
            <button onClick={reset}>Reset to {initialCount}</button>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
        </>
    );
};
// Preact Composition
const Counter = createComponent(props => {
    // run once
    const countRef = ref(props.initialCount);
    const reset = () => (countRef.value = props.initialCount);
    const increment = () => (countRef.value += 1);
    const decrement = () => (countRef.value -= 1);
    return ({ initialCount }) => (// run on each render
        <>
            Count: {countRef.value}
            <button onClick={reset}>Reset to {initialCount}</button>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
        </>
    );
});

Both implementations have mostly the same size and code looks alike, the difference is mostly that the composition functions only run once and the callbacks are not redeclared in each render.
It might not matters much, but having to swap event handlers in each render is not optimal and one of the reasons why React implemented SyntheticEvents.

useEffect

useEffect is a all in one effect handler, you can use it for mount (onMounted)/unmount(onUnmounted) lifecycles or for update based on dependencies.

// (P)React
const Comp = props => {
    useEffect(() => {
        // subscribe
        const subscription = props.source.subscribe();
        return () => {
            // Clean up the subscription
            subscription.unsubscribe();
        };
    }, []);
    return <div>irrelevant</div>;
};
// Preact Composition
const Comp = createComponent(props => {
    let subscription;
    onMounted(() => {
        // subscribe
        subscription = props.source.subscribe();
    });
    onUnmounted(() => {
        // Clean up the subscription
        subscription.unsubscribe();
    });
    return () => <div>irrelevant</div>;
});

Again, code for both approaches are mostly alike, useEffect will check dependencies and find the empty array making the effect never change and bail out the new function.

Now if you need to subscribe based on some dependency (eg. a prop) its a little bit difference.

// (P)React
const Comp = props => {
    useEffect(() => {
        const subscription = props.source.subscribe(props.id);
        return () => subscription.unsubscribe();
    }, [props.id, props.source]);
    return <div>irrelevant</div>;
};
// Preact Composition
const Comp = createComponent(props => {
    effect(
        props => [props.id, props.source],
        ([id, source], _oldArgs, onCleanup) => {
            const subscription = source.subscribe(id);
            onCleanup(() => subscription.unsubscribe());
        }
    );
    return () => <div>irrelevant</div>;
});

effect gives you 3 things, newArgs, oldArgs (in case of update), onCleanup that is a special function that you can call and pass a cleanup function. It does not use the return callback aproach because effect callback may be async!

useContext

useContext let you subscribe and get the value of a context in a parent component, in Composition API you can use the context as a source of a watch or effect function.

// (P)React
const Comp = props => {
    const ctxValue = useContext(MyContext);
    return <div>{ctxValue}</div>;
};
// Preact Composition
const Comp = createComponent(props => {
    const ctx = watch(MyContext);
    return () => <div>{ctx.value}</div>;
});

watch gives you some advantages and let you connect many sources together!

useReducer

There is no useReducer alternative yet, but it could be easly implemented

useCallback

In most scenarios, a useCallback like function is not necessary, as you can define your callbacks at setup time only once and the reference will never change, thats one of the great features of this API.
Normaly your callacks are called sync, so you can access your state and props references with the right values, but sometimes you may be passing a function to a component that will be called at a different time and you want that to be called with the current value.

// (P)React
const Comp = props => {
    const handlePostSubmit = useCallback(
        () => console.log('This will be called with actual id', props.id),
        [props.id]
    );
    return <Form onPostSubmit={handlePostSubmit}>irrelevant</Form>;
};
// Preact Composition
const Comp = createComponent(props => {
    const handlePostSubmit = watch(
        props => props.id,
        id => console.log('This will be called with actual id', id)
    );
    return () => <Form onPostSubmit={handlePostSubmit.value}>irrelevant</Form>;
});

useMemo

useMemo allows you to memoize values and avoid recalculating big values unless its need

// (P)React
const Comp = props => {
    const [filter, setFilter] = useState('ALL');
    const filteredItems = useMemo(() => filterItems(props.items, filter), [
        props.items,
        filter
    ]);
    return <ItemList items={filteredItems} />;
};
// Preact Composition
const Comp = createComponent(() => {
    const filterRef = ref('ALL');
    const filteredItems = watch(
        [props => props.items, filterRef],
        ([items, filter]) => filterItems(items, filter)
    );
    return () => <ItemList items={filteredItems.value} />;
});

useRef

useRef is used mainly for 2 things, handle DOM references and save components values between renders

As we have the setup function, all var's declared there can be used between renders, so no useRef needed.
For DOM values you can use callbacks and local var's or React.createRef

useImperativeHandle

Haven't found a need for it yet, but I beleve it can be implemented

useLayoutEffect

At the moment there is no direct replacement for this.

useDebugValue

Haven't found a need for it yet, but I beleve it can be implemented

Conclusion

The point here is not to say that this API is better, its different, both have pitfals as Evan You as pointed on Twitter: https://twitter.com/youyuxi/status/1169325119984082945

Compare

Discussion

markdown guide
 

I like it! Better than hooks. 👍

I only wish you'd do the obvious, idiomatic thing - instead of hiding the current component in a global variable, make it plain what's actually going on:

const Comp = createComponent((component) => {
    const filterRef = component.ref('ALL');
    const filteredItems = component.watch(
        [props => props.items, filterRef],
        ([items, filter]) => filterItems(items, filter)
    );
    return () => <ItemList items={filteredItems.value} />;
});

Yes, slightly more repetitive - but takes all the initial mystery out of it, and avoids teaching newbs how to be "clever".

I don't know why anybody thinks it's more "elegant" to hide things in global state.

In my world, obvious beats clever, every time. Code should do what it looks like it does. That's my only real gripe with hooks. 🤷‍♂️

 

I like that concept also, it can solve some issues, but may introduce others.

Using named exports alow for better tree-shaking and better minification, making all composition functions optional.
The way you specify it needs to create a object with all the functions binded.

Thanks for bringing this to discussion. We can find the pros and cons of both approachs and decide where to go!

 

Using named exports alow for better tree-shaking and better minification, making all composition functions optional.

True.

But it doesn't have to be OOP - that was just an example.

We can do the same thing with functions:

const Comp = createComponent(component => {
    const filterRef = ref(component, 'ALL');
    const filteredItems = watch(
        component,
        [props => props.items, filterRef],
        ([items, filter]) => filterItems(items, filter)
    );
    return () => <ItemList items={filteredItems.value} />;
});

My only point is don't hide your dependencies in global state.

I see your ideas.
Would be nice to discuss this better. Could you post your concern on the PR github.com/preactjs/preact/pull/1923
or on Preact Slack
preact.slack.com/