8.1.3 • Published 4 years ago

react-fp-context v8.1.3

Weekly downloads
1
License
MIT
Repository
github
Last release
4 years ago

React FP Context

react-fp-context is a library that wraps the React Context with a functional API (lodash/fp API) that makes it easier to work with state. It provides you with a single way to get your state and a single way to update it without the need for selectors, actions, reducers, types, connectors, etc.

Background

The idea arose during a Hackathon in Fiverr (the company I work for) where @igor-burshtein and I had to develop a full React project over the course of just 2 days. With such a fast turnaround, there was no time to set up the usual structure with actions, selectors, reducers, etc, but we still found ourselves in need of a quick & easy way to manage state. And, so, enter this library.

(BTW we won in the Hackathon!)

Installation

npm i react-fp-context --save

Options

OptionDescriptionDefault Value
ContextA React Context as the state containerRequired
initialPropsMapper?A mapper to map the Root Component props to a different state shape_.identity
effects?A list of effects[]
derivedStateSyncers?A list of derived state syncers[]
debug?Debug mode to trace state updates in the consolefalse

Important Notice

React Context and useContext is often used to avoid prop drilling, however it's known that there's a performance issue. When a context value is changed, all components that useContext will re-render even those who consume a slice of the context that didn't changed. (react-redux maintainers have talked about this pain in a big discussion).

React team have talked about introducing something called Context Selectors to solve this issue where component only re-render if and only if the selector returns a different value of that slice. Unfortunately, this is something that will need a refactor in React infrastructure and a multi-month project.

Once the Context Selector proposal land we'll add Hooks as another way to get/update the state as well as keeping the connect for backward compatibility.

Usage

This library exposes a ContextProvider-like HOC that can be passed configurations & the Root component we have in our app.

As an example, let's use a standard Counter application where we can increment/decrement a counter whilst the value is being displayed.

Apr-23-2020 11-48-06

This application has 3 components (Counter, Display and Controls) with <Display/> and <Controls/> being the children of <Counter/> (the Root component of our app).

First step is to create the context:

import React from 'react';

const CounterContext = React.createContext();
export default CounterContext;

then we wrap our Root component (<Counter/>) like this:

import ReactFpContextProvider from 'react-fp-context';
import CounterContext from './CounterContext';

const Counter = () => {
    return (
        <div className="counter">
            <Display/>
            <Controls/>
        </div>
    );
};

export default ReactFpContextProvider({
    Context: CounterContext
})(Counter);

As you can see, the context is being sent to ReactFpContext as part of its options.

Let us now render our Root component with its initial props (only the current counter value):

export const CounterAt_0 = () => {
    return (<Counter count={0}/>);
};

The <Display/> component, which is a child of Root, can now reach the state easily.

import { connect } from 'react-fp-context';
import CounterContext from './CounterContext';

const Display = ({ count }) => {
    return (
        <div className="display">
            {count}
        </div>
    )
};

const useStateToProps = ({ context }) => ({
    count: context.count
});

export default connect(useStateToProps)(Display);

In our second child, <Controls/>, we want to update the state:

import { connect } from 'react-fp-context';
import CounterContext from './CounterContext';

const Controls = ({ onAddition, onDecrement }) => {
    return (
        <div className="controls">
            <div className="control" onClick={onAddition}>+</div>
            <div className="control" onClick={onDecrement}>-</div>
        </div>
    )
};

const useStateToProps = ({ context, setContext }) => {
    const { count } = context;

    // NOTE: All the inline functions MUST be wrapped with React.useCallback,
    // so we can take the benefit of `useMemo` that we use in order to not re-render unnecessary components.
    // without this, inline function will get reference on each render and our `connect` can't do the performance optimization
    const onAddition = React.useCallback(() => {
        setContext('count', count + 1);
    }, [setContext, count]);

    const onDecrement = React.useCallback(() => {
        setContext('count', count + 1);
    }, [setContext, count]);

    return {
        onAddition,
        onDecrement
    };
}

export default connect(useStateToProps)(Controls);

Please note: Multiple setContext calls will be batched based on React.setState batching whilst having only one render phase. Also, setContext is using the lodash/fp#set and lodash/fp#update methods under the hood which means that you can update nested paths and arrays easily.

    // create complex path that do not exist.
    setContext('new.path.that.not.exist', 5);
    // { new: { path: { that: { not: { exist: 5 } } } } }

    // add new member to nested members array.
    setContext('add.member.to.members', (members) => members.concat({ name: 'test' }));
    // { add: { member: { to: { members: [{ name: 'myself' }, { name: 'test' }] } } } }

Up until now, we can see that the Context we passed into the options of the library has wrapped the values of the Root props and we can inspect the state by extracting { context } and update it by extracting { setContext }.

initialPropsMapper Option

But what if we need to map the Root props to a different state structure? Easy. We just pass another option (initialPropsMapper):

import ReactFpContextProvider from 'react-fp-context';
import CounterContext from './CounterContext';

const Counter = () => {
    return (
        <div className="counter">
            <Display/>
            <Controls/>
        </div>
    );
};

export default ReactFpContextProvider({
    Context: CounterContext,
    initialPropsMapper: ({ count }) => ({ mystate: { count }})
})(Counter);

If you console log your Context, you'll see that it now received the new shape.

(We can even compare objects by referental equality (===) inlodash/fp since updates break the reference in the changed object upto the upper parent reference, so we can distinguish changes in each level without having to do expensive diffing.)

effects Option

There are two other major principles of react-fp-context - the handling of effects and derived states.

Let's say that we have an effect that pops an alert message (or triggers a service request) if a specific condition is met (e.g. once the counter hits 10). In order to do this, we need access to context and setContext in our effects which allows us to inspect and respond with updates:

import React from 'react';

const useRequestReportOnTen = ({ context }) => {
    const { count } = context;

    React.useEffect(() => {
        if (count !== 10) { return; }

        alert('Got ten and sending to the backend!');
    }, [count]);
};

export default useRequestReportOnTen;

First we define our hook - then we inject it into our options:

import ReactFpContextProvider from 'react-fp-context';
import CounterContext from './CounterContext';

const Counter = () => {
    return (
        <div className="counter">
            <Display/>
            <Controls/>
        </div>
    );
};

export default ReactFpContextProvider({
    Context: CounterContext,
    effects: [useRequestReportOnTen]
})(Counter);

(react-fp-context will inject ({ context, setContext }) into all these effects arrays.)

derivedStateSyncers Option

The next thing is the syncing of derived states. But why would you need derived state handling different from effects when you can simply use effects and be done with it?

Below, we'll present the effects solution of syncing states. We want to color the even numbers with blue and the odd numbers with red in the <Display/> component. (You may think that it can be computed in the render phase, but we want to see it in another place, so we need it in the state).

Apr-23-2020 13-20-45

Here's the implementation:

import React from 'react';

const useBlueOnEvenRedOnOdd = ({ context, setContext }) => {
    const { count } = context;

    React.useEffect(() => {
        setContext('color', count % 2 === 0 ? 'blue' : 'red');
    }, [count, setContext]);
};

export default useBlueOnEvenRedOnOdd;

That's really nice! Whenever the count changes, we update the color in the useEffect. But there's something missing in this implementation. Let's build it in another way while consoling some logs:

import React from 'react';

const useBlueOnEvenRedOnOdd = ({ context, setContext }) => {
    const { count, color } = context;

    React.useEffect(() => {
        console.log('unsynced state at some point in effects', { count, color });
        const newColor = count % 2 === 0 ? 'blue' : 'red';

        if (newColor === color) { return; }

        setContext('color', newColor);
    }, [count, setContext, color]);
};

export default useBlueOnEvenRedOnOdd;

If we run this and click just once on the control, we will get these logs:

image

We're rendering the component twice since the count was changed and the sync only starts in useEffect (after the render phase). But the unnecessary re-render isn't the worst thing - at some point in our effect, we got a unsynced state of number 1 being blue first and after that the sync comes which means that we can't be sure that our state is actually synced in our effects.

How can we solve this situation? Since we're syncing states and we aren't using the Browser API (DOM Mutations or Async Operations), we have another option to pass for derivedState syncing called derivedStateSyncers. First we define a function:

const blueOnEvenRedInOdd = ({ context, prevContext, setContext }) => {
    const { count } = context;
    const { count: prevCount } = prevContext;

    if (count === prevCount) { return; }

    setContext('color', count % 2 === 0 ? 'blue' : 'red');
};

export default blueOnEvenRedInOdd;

This function receives the context, setContext, prevContext (empty object {} in initial render) and updates the color based on changes in the count.

After that we define this syncer in our syncers list:

import ReactFpContextProvider from 'react-fp-context';
import CounterContext from './CounterContext';

const Counter = () => {
    return (
        <div className="counter">
            <Display/>
            <Controls/>
        </div>
    );
};

export default ReactFpContextProvider({
    Context: CounterContext,
    derivedStateSyncers: [blueOnEvenRedInOdd]
})(Counter);

Now we get synced state at each point of time in our effects and we also have just one render (even with the two changes - count and color) thanks to React.setState batching.

Remember that you can always mix effects and derivedStateSyncers at the same time whenever it fits your purpose.

debug Option

You can debug and trace your state updates by passing this option as true. Once you do you will see logs in the console that will make it easy to track the execution flow.

image

8.1.3

4 years ago

8.1.2

4 years ago

8.1.1

4 years ago

8.1.0

4 years ago

8.0.0

4 years ago

7.1.0

4 years ago

7.0.0

4 years ago

6.0.2

4 years ago

6.0.1

4 years ago

6.0.0

4 years ago

6.0.0-rc2

4 years ago

6.0.0-rc

4 years ago

5.0.3

4 years ago

5.0.3-rc

4 years ago

5.0.2

4 years ago

5.0.1

4 years ago

5.0.0

4 years ago

3.0.0

4 years ago

2.0.1-rc2

4 years ago

4.0.1

4 years ago

2.0.1-rc4

4 years ago

4.0.0

4 years ago

2.0.1-rc3

4 years ago

4.0.3

4 years ago

2.0.1-rc6

4 years ago

4.0.2

4 years ago

2.0.1-rc5

4 years ago

2.0.1-rc

4 years ago

2.0.1

4 years ago

2.0.0

4 years ago

1.0.0

4 years ago