user1713450
user1713450

Reputation: 1429

Limit renders of component that uses useContext via a useFooController/useFooHook

I'm using custom hooks for a component, and the custom hook uses a custom context. Consider

/* assume FooContext has { state: FooState, dispatch: () => any } */

const useFoo = () => {
  const { state, dispatch } = useContext(FooContextContext)
  return {apiCallable : () => apiCall(state) }
}

const Foo = () => {
  const { apiCallable } = useFoo()
  return (
    <Button onClick={apiCallable}/>
  )
}

Lots of components will be making changes to FooState from other components (form inputs, etc.). It looks to me like Foo uses useFoo, which uses state from FooStateContext. Does this mean every change to FooContext will re-render the Foo component? It only needs to make use of state when someone clicks the button but never otherwise. Seems wasteful.

I was thinking useCallback is specifically for this, so I am thinking return {apiCallable : useCallback(() => apiCall(state)) } but then I need to add [state] as a second param of useCallback. Then that means the callback will be re-rendered whenever state updates, so I'm back at the same issue, right?

This is my first time doing custom hooks like this. Having real difficulty understanding useCallback. How do I accomplish what I want?

Edit Put another way, I have lots of components that will dispatch small changes to deeply nested properties of this state, but this particular component must send the entire state object via a RESTful API, but otherwise will never use the state. It's irrelevant for rendering this component completely. I want to make it so this component never renders even when I'm making changes constantly to the state via keypresses on inputs (for example).

Upvotes: 2

Views: 424

Answers (2)

Dennis Vash
Dennis Vash

Reputation: 53874

Does this mean every change to FooContext will re-render the Foo component?

Currently (v17), there is no bailout for Context API. Check my another answer for examples. So yes, it will always rerender on context change.

It only needs to make use of state when someone clicks the button but never otherwise. Seems wasteful.

Can be fixed by splitting context providers, see the same answer above for explanation.

Upvotes: 0

Jacob Smit
Jacob Smit

Reputation: 2379

Since you provided Typescript types in your question, I will use them in my response.

Way One: Split Your Context

Given a context of the following type:

type ItemContext = {
    items: Item[];
    addItem: (item: Item) => void;
    removeItem: (index: number) => void;
}

You could split the context into two separate contexts with the following types:

type ItemContext = Item[];
type ItemActionContext = {
    addItem: (item: Item) => void;
    removeItem: (index: number) => void;
}

The providing component would then handle the interaction between these two contexts:

const ItemContextProvider = () => {
    const [items, setItems] = useState([]);

    const actions = useMemo(() => {
        return {
            addItem: (item: Item) => {
                setItems(currentItems => [...currentItems, item]);
            },
            removeItem: (index: number) => {
                setItems(currentItems => currentItems.filter((item, i) => index === i));
            }
        };
    }, [setItems]);

    return (
        <ItemActionContext.Provider value={actions}>
            <ItemContext.Provider value={items}>
                {children}
            </ItemContext.Provider>
        </ItemActionContext.Provider>
    )
};

This would allow you to get access to two different contexts that are part of one larger combined context.

The base ItemContext would update as items are added and removed causing rerenders for anything that was consuming it.

The assoicated ItemActionContext would never update (setState functions do not change for their lifetime) and would never directly cause a rerender for a consuming component.

Way Two: Some Version of an Subscription Based Value

If you make the value of your context never change (mutate instead of replace, HAS THE WORLD GONE CRAZY?!) you can set up a simple object that holds the data you need access to and minimises rerenders, kind of like a poor mans Redux (maybe it's just time to use Redux?).

If you make a class similar to the following:

type Subscription<T> = (val: T) => void;
type Unsubscribe = () => void;

class SubscribableValue<T> {
    private subscriptions: Subscription<T>[] = [];
    private value: T;

    constructor(val: T) {
        this.value = val;

        this.get = this.get.bind(this);
        this.set = this.set.bind(this);
        this.subscribe = this.subscribe.bind(this);
    }

    public get(): T {
        return this._val;
    }

    public set(val: T) {
        if (this.value !== val) {
            this.value = val;
            this.subscriptions.forEach(s => {
                s(val)
            });
        }
    }

    public subscribe(subscription: Subscription<T>): Unsubscriber {
        this.subscriptions.push(subscription);
        return () => {
            this.subscriptions = this.subscriptions.filter(s => s !== subscription);
        };
    }
}

A context of the following type could then be created:

type ItemContext = SubscribableValue<Item[]>;

The providing component would look something similar to:

const ItemContextProvider = () => {

    const subscribableValue = useMemo(() => new SubscribableValue<Item[]>([]), []);

    return (
        <ItemContext.Provider value={subscribableValue}>
            {children}
        </ItemContext.Provider>
    )
};

You could then use some a custom hooks to access the value as needed:

// Get access to actions to add or remove an item.
const useItemContextActions = () => {
    const subscribableValue = useContext(ItemContext);

    const addItem = (item: Item) => subscribableValue.set([...subscribableValue.get(), item]);
    const removeItem = (index: number) => subscribableValue.set(subscribableValue.get().filter((item, i) => i === index));

    return {
        addItem,
        removeItem
    }
}

type Selector = (items: Item[]) => any;

// get access to data stored in the subscribable value.
// can provide a selector which will check if the value has change each "set"
// action before updating the state.
const useItemContextValue = (selector: Selector) => {
    const subscribableValue = useContext(ItemContext);

    const selectorRef = useRef(selector ?? (items: Item[]) => items)

    const [value, setValue] = useState(selectorRef.current(subscribableValue.get()));

    const useEffect(() => {
        const unsubscribe = subscribableValue.subscribe(items => {
            const newValue = selectorRef.current(items);

            if (newValue !== value) {
                setValue(newValue);
            }
        })

        return () => {
            unsubscribe();
        };
    }, [value, selectorRef, setValue]);

    return value;
}

This would allow you to reduce rerenders using selector functions (like an extremely basic version of React Redux's useSelector) as the subscribable value (root object) would never change reference for its lifetime.

The downside of this is that you have to manage the subscriptions and always use the set function to update the held value to ensure that the subscriptions will be notified.

Conclusion:

There are probably a number of other ways that different people would attack this problem and you will have to find one that suits your exact issue.

There are third party libraries (like Redux) that could also help you with this if your context / state requirements have a larger scope.

Upvotes: 2

Related Questions