Is it possible to use the same <Context.Provider> on multiple components? - reactjs

I know that I can wrap HOC with my <Context.Provider> and consume it in all child components.
I would like to consume context in two separate components but they are nested somewhere deeply and closest parent of them is somewhere in the app root. I don't want to provide context to (almost) all components, so I was wondering is it possible to wrap only those two components?
I tried to do it but only first component gets context.
The App structure looks like this:
<App>
<A1>
<A2>
<MyContext.Provider>
<Consumer1/>
</MyContext.Provider>
</A2>
</A1>
<B1>
<B2>
<MyContext.Provider>
<Consumer2/>
</MyContext.Provider>
</B2>
</B1>
</App>
EDIT: I was wrong thinking that wrapping root component will make re-render all child components on context change. Only consumers will rerender so it's perfectly fine to wrap root component.

If you want to have a single value which is shared between multiple parts of the application, then in some form you will need to move that value up to the common ancestor component of the ones that need to consume the value. As you mentioned in the comments, your issue is one of performance and trying not to rerender everything. Having two providers doesn't really help with this, because there will still need to be some component making sure both providers are providing the same value. So that component will end up needing to be a common ancestor of the two providers.
Instead, you can use shouldComponentUpdate (for class components) or React.memo (for functional components) to stop the rerendering process from working its way down the component tree. Deep descendants which are using Context.Consumer will still rerender, and so you can skip over the middle parts of your tree. Here's an example (note the use of React.memo on the intermediate component):
const Context = React.createContext(undefined);
const useCountRenders = (name) => {
const count = React.useRef(0);
React.useEffect(() => {
count.current++;
console.log(name, count.current);
});
}
const App = () => {
const [val, setVal] = React.useState(1);
useCountRenders('App');
React.useEffect(() => {
setTimeout(() => {
console.log('updating app');
setVal(val => val + 1)
}, 1000);
}, [])
return (
<Context.Provider value={val}>
<IntermediateComponent />
</Context.Provider>
);
}
const IntermediateComponent = React.memo((props) => {
useCountRenders('intermediate');
return (
<div>
<Consumer name="first consumer"/>
<UnrelatedComponent/>
<Consumer name="second consumer"/>
</div>
);
})
const Consumer = (props) => {
useCountRenders(props.name);
return (
<Context.Consumer>
{val => {
console.log('running consumer child', props.name);
return <div>consuming {val}</div>
}}
</Context.Consumer>
)
}
const UnrelatedComponent = (props) => {
useCountRenders('unrelated');
return props.children || null;
}
ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.10.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.10.2/umd/react-dom.development.js"></script>
<div id="root"></div>
When you run the above code, check the logs to see which components rerender. On the first pass, everything renders, but then after a second when the app state changes, only app rerenders. IntermediateComponent, UnrelatedComponent, and even Consumer don't rerender. The function inside the Context.Consumer does rerun, and any thing returned by that function (in this case just a div) will rerender.

As requested by the OP this solution uses mostly hooks but useReducer cannot achieve state sharing under separate providers (as far as I've tried).
It does not require one Provider to be at the root of the app and different reducer can be used for each Provider.
It uses a static state manager but that's an implementation detail, to share the state between several component under different context proviver, one will need something like a shared reference to the state, a way to change it and a way to notify of these changes.
When using the snippet the first button shows the shared state and increment foo when clicked, the second button shows the same shared state and increments bar when clicked:
// The context
const MyContext = React.createContext();
// the shared static state
class ProviderState {
static items = [];
static register(item) {
ProviderState.items.push(item);
}
static unregister(item) {
const idx = ProviderState.items.indexOf(item);
if (idx !== -1) {
ProviderState.items.splice(idx, 1);
}
}
static notify(newState) {
ProviderState.state = newState;
ProviderState.items.forEach(item => item.setState(newState));
}
static state = { foo: 0, bar: 0 };
}
// the state provider which registers to (listens to) the shared state
const Provider = ({ reducer, children }) => {
const [state, setState] = React.useState(ProviderState.state);
React.useEffect(
() => {
const entry = { reducer, setState };
ProviderState.register(entry);
return () => {
ProviderState.unregister(entry);
};
},
[]
);
return (
<MyContext.Provider
value={{
state,
dispatch: action => {
const newState = reducer(ProviderState.state, action);
if (newState !== ProviderState.state) {
ProviderState.notify(newState);
}
}
}}
>
{children}
</MyContext.Provider>
);
}
// several consumers
const Consumer1 = () => {
const { state, dispatch } = React.useContext(MyContext);
// console.log('render1');
return <button onClick={() => dispatch({ type: 'inc_foo' })}>foo {state.foo} bar {state.bar}!</button>;
};
const Consumer2 = () => {
const { state, dispatch } = React.useContext(MyContext);
// console.log('render2');
return <button onClick={() => dispatch({ type: 'inc_bar' })}>bar {state.bar} foo {state.foo}!</button>;
};
const reducer = (state, action) => {
console.log('reducing action:', action);
switch(action.type) {
case 'inc_foo':
return {
...state,
foo: state.foo + 1,
};
case 'inc_bar':
return {
...state,
bar: state.bar + 1,
};
default:
return state;
}
}
// here the providers are used on the same level but any depth would work
class App extends React.Component {
render() {
console.log('render app');
return (
<div>
<Provider reducer={reducer}>
<Consumer1 />
</Provider>
<Provider reducer={reducer}>
<Consumer2 />
</Provider>
<h2>I&apos;m not rerendering my children</h2>
</div>
);
}
}
ReactDOM.render(<App />, document.getElementById('root'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.0/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.0/umd/react-dom.production.min.js"></script>
<div id="root"></div>
In the end we have re created something a little like redux with a static state shared by several Provider/Consumer ensembles.
It will work the same no matter where and how deep the providers and consumers are without relying on long nested update chains.
Note that as the OP requested, here there is no need for a provider at the root of the app and therefore the context is not provided to every component, just the subset you choose.

Related

Redux Toolkit / Jest / renderHook / testing the function returned by a hook with providers

So i have a hook, called useNavigation, it returns a function called navigateForwards. It handles the page validation etc - given it interacts with various providers like redux and context the hook needs wrapping and that state provided - and variable on a test by test case.
What do i need to do to make that happen? I have a helper like this for components, i want something similar for hooks.
const renderWithProviders = (
component,
{
reduxState = {}, // all passing of data to the providers (maybe a mock initial state in tests)
store = setupStore(reduxState), // redux store mock - should be recreated in each test
stateProviderProps = defaultStateProviderProps({
formSettings: initialState(),
}), // context props (not its state)
mockedProviderProps = { mocks: [], addTypename: false }, // i.e. default props for context provider
...renderOptions // allow addition to the render function
} = {}
) => {
const Wrapper = ({ children }) => {
return (
<Provider store={store}>
{/* TODO don't forget to remove StateProvider - i.e. context from here when redux is done, it is echoed as stateProviderProps function was created that way and changing means changing all tests */}
<StateProvider {...stateProviderProps}>
<MockedProvider {...mockedProviderProps}>
<ThemeProvider theme={themes.chameleon}>{children}</ThemeProvider>
</MockedProvider>
</StateProvider>
</Provider>
);
};
// Return an object with the store and all of RTL's query functions
return {
store,
...render(component, { wrapper: Wrapper, ...renderOptions }),
};
};
This is how it is called.
renderWithProviders(<BrowserMessages />, {
stateProviderProps: {
formSettings: initialState(),
},
reduxState: {
optionalFormConfig: { iFrameId: undefined },
},
});
This is how i see it happening for hooks
export const renderHookWithProviders = (
hook,
{
reduxState = {}, // all passing of data to the providers (maybe a mock initial state in tests)
store = setupStore(reduxState), // redux store mock - should be recreated in each test
stateProviderProps = defaultStateProviderProps({
formSettings: initialState(),
}), // context props (not its state)
mockedProviderProps = { mocks: [], addTypename: false }, // i.e. default props for context provider
...renderHookOptions // allow addition to the render function
} = {}
) => {
const Wrapper = ({ children }) => {
return (
<Provider store={store}>
{/* TODO don't forget to remove StateProvider - i.e. context from here when redux is done, it is echoed as stateProviderProps function was created that way and changing means changing all tests */}
<StateProvider {...stateProviderProps}>
<MockedProvider {...mockedProviderProps}>
<ThemeProvider theme={themes.chameleon}>{children}</ThemeProvider>
</MockedProvider>
</StateProvider>
</Provider>
);
};
// Return an object with the store and all of RTL's query functions
return {
store,
...renderHook(hook, { wrapper: Wrapper, ...renderHookOptions }),
};
};
Being called like this
const { store, result } = renderHookWithProviders(
() => useNavigationHook,
{
stateProviderProps: {
formSettings: initialState(),
},
reduxState: {
paging: mockPaging(),
components: mockComponents(),
navigation: mockNavigation(),
},
}
);
act(() => {
result.current();
});
Now we have result.current which appears as a function "useNavigation", but when i call it (hoping it is wrapped with providers and in a wrap component). I get a must call hooks within a function component error.... help / thank you!

Why react component can re-render when redux reducer do not return a new array?

We know that if we want to re-render a react component connected by react-redux, we should return a new array in reducer when we dispatch a related action. My problem is that why react component can re-render when redux reducer do not return a new array?
When I click App component button, first app name becomes 'aaa', why???
actions:
export function setAppName(name: string) {
return {
type: 'SET_APP_NAME',
name,
}
}
reducers:
export function appInfos(state = {
appInfos: [
{name: 'ccc'},
{name: 'ddd'},
]
}, action: any) {
switch(action.type) {
case 'SET_APP_NAME':
// just return origin array, not a new array
const appInfos = state.appInfos;
appInfos[0].name = action.name
return Object.assign({}, state, { appInfos })
default:
return state;
}
}
component App:
function mapStateToProps(state: any) {
return {
app: state.appInfos
}
}
function App() {
const { app } = useSelector(mapStateToProps)
const dispatch = useDispatch()
const onClick = useCallback(() => {
dispatch(setAppName('aaa'))
}, [dispatch])
return (
<div className="app">
{app.appInfos.map((info: any) => {
return <div key={info.name}>{info.name}</div>
})}
<button onClick={onClick}>click</button>
</div>
);
}
container & store:
const store = createStore(combineReducers({
appInfos
}))
ReactDOM.render(
<Provider store={store}>
<React.StrictMode>
<App />
</React.StrictMode>
</Provider>,
document.getElementById('root')
);
You're mixing up usage for connect and useSelector, and that's causing unnecessary renders.
connect accepts a mapStateToProps function as an argument, which must return an object because all fields will become new props to the component. That component will re-render if any individual field changes to a new reference:
https://react-redux.js.org/using-react-redux/connect-mapstate
However, useSelector accepts a selector function that must return one value, and it will re-render if that one reference changes to a new value:
https://react-redux.js.org/api/hooks#equality-comparisons-and-updates
Right now, you are writing a function that is always returning a new object reference. Your function will always run after every dispatched action, and thus it will always force the component to re-render.
Change it to just be:
function selectAppInfo(state) {
return state.appInfos
}

React.memo issue with Redux

I have two components.
function Parent(props){
const handleClick = () => {
console.log(props.stateA);
};
return <div><Child text={stateB} handleClick={handleClick} /></div>
}
const mapStateToProps = (state) => {
return {
stateA: state.stateA // stateA will be changed somewhere else
stateB: state.stateB
}
};
export default connect(mapStateToProps)(Parent);
function Child(props) {
return <div onClick={props.handleClick}>{props.text}</div>
}
export default React.memo(Child,(prev, next) => {
return prev.text === next.text
});
My problem is when stateA is changed somewhere, clicking on Child will log the previous stateA. I can't access the latest stateA.
You can see, I don't want to Child re-render when stateA changes,it should re-render only when stateB changed. But I want to access the latest stateA in Parent when clicking on Child.
Is there any method to solve this problem?
If the Parent component is a functional component then you can use like this
const [valueA, setValueA] = useState('')
useEffect(() => {
setValueA(props.stateA)
},[props.stateA])
console.log(valueA) // latest Value of stateA
return <div><Child text={stateB} handleClick={handleClick} /></div>
I hope it'll work for you.
You should be able to access props.stateA no problem
const handleClick = () => {
console.log(props.stateA);
};
because you accessing parent's props in handleClick. So if props.stateA is stale then the logical conclusion is the parent doesn't receive the latest props. Can we see how you update props/state?
The problem you are experiencing has nothing to do with Redux.
The Parent component passes 2 props to the child: the text which is changed when needed and handleClick which is changed each render of the Parent component - a new function is created each time.
But the React.memo is checking only the text prop, so the child receives a stale handleClick quite often.
The correct solution is to wrap the handleClick with useCallback and check all props in React.memo (react does this by default).
function Parent(props){
const handleClick = useCallback(() => {
console.log(props.stateA);
}, []);
return <div><Child text={stateB} handleClick={handleClick} /></div>
}
const mapStateToProps = (state) => {
return {
stateA: state.stateA // stateA will be changed somewhere else
stateB: state.stateB
}
};
export default connect(mapStateToProps)(Parent);
function Child(props) {
return <div onClick={props.handleClick}>{props.text}</div>
}
export default React.memo(Child);
You can keep a ref to stateA so it is what is logged when you call handleClick. useRef ensures that the last value is used.
function Parent(props){
const stateARef = useRef(props.stateA);
useEffect(() => {
stateARef.current = props.stateA;
}, [props.stateA])
const handleClick = () => {
console.log(stateARef.current);
};
return <div><Child text={stateB} handleClick={handleClick} /></div>
}

Memoize functional component using react-redux, reselect and React.memo()

I have built an app on ReactJS 16.8.5 and React-Redux 3.7.2. When the app loads the app mounts, initial store is set and database subscriptions are set up against a Firebase Realtime Database.
The app contains a header, Sidebar and content section.
I have implemented reselect along with React.memo to avoid rerendring when props change, but the Sidebar component is still re-rendering.
Using React profiler API and a areEqual comparison function in React.memo I can see that the Sidebar is being rendered several times although props are equal.
app.js
//Imports etc...
const jsx = (
<React.StrictMode>
<Provider store={store}>
<AppRouter />
</Provider>
</React.StrictMode>
)
let hasRendered = false
const renderApp = () => {
if (!hasRendered) { //make sure app only renders one time
ReactDOM.render(jsx, document.getElementById('app'))
hasRendered = true
}
}
firebase.auth().onAuthStateChanged((user) => {
if (user) {
// Set initial store and db subscriptions
renderApp()
}
})
AppRouter.js
//Imports etc...
const AppRouter = ({}) => {
//...
return (
<React.Fragment>
//uses Router instead of BrowserRouter to use our own history and not the built in one
<Router history={history}>
<div className="myApp">
<Route path="">
<Sidebar ...props />
</Route>
//More routes here...
</div>
</Router>
</React.Fragment>
)
}
//...
export default connect(mapStateToProps, mapDispatchToProps)(AppRouter)
Sidebar.js
//Imports etc...
export const Sidebar = (props) => {
const onRender = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
if (id !== 'Sidebar') { return }
console.log('onRender', phase, actualDuration)
}
return (
<Profiler id="Sidebar" onRender={onRender}>
<React.Fragment>
{/* Contents of Sidebar */}
</React.Fragment>
</Profiler>
}
const getLang = state => (state.usersettings) ? state.usersettings.language : 'en'
const getMediaSize = state => (state.route) ? state.route.mediaSize : 'large'
const getNavigation = state => state.navigation
const getMyLang = createSelector(
[getLang], (lang) => console.log('Sidebar lang val changed') || lang
)
const getMyMediaSize = createSelector(
[getMediaSize], (mediaSize) => console.log('Sidebar mediaSize val changed') || mediaSize
)
const getMyNavigation = createSelector(
[getNavigation], (navigation) => console.log('Sidebar navigation val changed') || navigation
)
const mapStateToPropsMemoized = (state) => {
return {
lang: getMyLang(state),
mediaSize: getMyMediaSize(state),
navigation: getMyNavigation(state)
}
}
const areEqual = (prevProps, nextProps) => {
const areStatesEqual = _.isEqual(prevProps, nextProps)
console.log('Sidebar areStatesEqual', areStatesEqual)
return areStatesEqual
}
export default React.memo(connect(mapStateToPropsMemoized, mapDispatchToProps)(Sidebar),areEqual)
Initial render looks ok up until Sidebar navigation val changed - after that the component re-renders a whole bunch of times - why!?
Console output - initial render
onRender Sidebar mount 572
Sidebar mediaSize val changed
Profile Sidebar areEqual true
Sidebar navigation val changed
onRender Sidebar update 153
Sidebar navigation val changed
onRender Sidebar update 142
onRender Sidebar update 103
onRender Sidebar update 49
onRender Sidebar update 5
onRender Sidebar update 2
onRender Sidebar update 12
onRender Sidebar update 3
onRender Sidebar update 2
onRender Sidebar update 58
onRender Sidebar update 2
onRender Sidebar update 4
onRender Sidebar update 5
onRender Sidebar update 4
The subsequent render does not affect any part of the store that is mapped to props (location), but component is still re-rendering.
Console output - subsequent render
Profile Sidebar areEqual true
onRender Sidebar update 76
onRender Sidebar update 4
I expect Sidebar to be memoized and only render/re-render a few times during mount/update of store during initial load.
Why is the Sidebar component being rendered so many times?
Kind regards /K
The React.memo is not needed because react-redux connect will return a pure component that will only re render if you change the props passed or after a dispatched action caused any changes in the state.
Your mapStateToPropsMemoized should work (see update) but probalby better to write it this way:
const mapStateToPropsMemoized = createSelector(
getMyLang,
getMyMediaSize,
getMyNavigation,
(lang, mediaSize, navigation) => ({
lang,
mediaSize,
navigation,
})
);
//using react.redux connect will return a pure component and passing that
// to React.memo should cause an error because connect does not return a
// functional component.
export default connect(
mapStateToPropsMemoized,
mapDispatchToProps
)(Sidebar);
UPDATE
Your getState should work.
I cannot reproduce component re rendering with your code. The object returned from mapState is a new object every time but it's direct properties never change because the selectors always return memoized result. See example below
const { useRef, useEffect } = React;
const {
Provider,
useDispatch,
connect,
useSelector,
} = ReactRedux;
const { createStore } = Redux;
const { createSelector } = Reselect;
const state = { someValue: 2, unrelatedCounter: 0 };
//returning a new state every action someValue
// never changes, only counter
const reducer = (state) => ({
...state,
unrelatedCounter: state.unrelatedCounter + 1,
});
const store = createStore(
reducer,
{ ...state },
window.__REDUX_DEVTOOLS_EXTENSION__ &&
window.__REDUX_DEVTOOLS_EXTENSION__()
);
//selectors
const selectSomeValue = (state) => state.someValue;
//selectors only return a new object if someValue changes
const selectA = createSelector(
[selectSomeValue],
() => ({ value: 'A' }) //returns new object if some value changes
);
const selectB = createSelector(
[selectSomeValue],
() => ({ vale: 'B' }) //returns new object if some value changes
);
const selectC = createSelector(
[selectSomeValue],
() => ({ vale: 'C' }) //returns new object if some value changes
);
const Counter = () => {
const counter = useSelector(
(state) => state.unrelatedCounter
);
return <h4>Counter: {counter}</h4>;
};
const AppComponent = (props) => {
const dispatch = useDispatch();
const r = useRef(0);
//because state.someValue never changes this component
// never gets re rendered
r.current++;
useEffect(
//dispatch an action every second, this will create a new
// state but state.someValue never changes
() => {
setInterval(() => dispatch({ type: 88 }), 1000);
},
[dispatch] //dispatch never changes but linting tools don't know that
);
return (
<div>
<h1>Rendered {r.current} times</h1>
<Counter />
<pre>{JSON.stringify(props, undefined, 2)}</pre>
</div>
);
};
const mapStateToProps = (state) => {
return {
A: selectA(state),
B: selectB(state),
C: selectC(state),
};
};
const App = connect(mapStateToProps)(AppComponent);
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.5/redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-redux/7.2.0/react-redux.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>
<div id="root"></div>

How is "ReactReduxContext.Consumer" working?

I wrote a demo to try to understand how "ReactReduxContext.Consumer" works, the main code like this:
Hello.tsx
export default function Hello() {
return <ReactReduxContext.Consumer>
{({store}) => {
return <div>
<ul>
<input value={store.getState().name} onChange={(e) => store.dispatch(changeNameAction(e.target.value))}/>
<div>{JSON.stringify(store.getState())}</div>
</ul>
</div>
}}
</ReactReduxContext.Consumer>
}
Entry.tsx
ReactDOM.render(
<Provider store={store}>
<Hello/>
</Provider>,
document.body
);
State.ts
export type State = {
name: string
};
reducer.ts
const initStore: State = {
name: 'aaa'
}
export default function reducers(state = initStore, action: ChangeNameAction): State {
switch (action.type) {
case 'CHANGE_NAME':
return {
...state,
name: action.name
};
default:
return state;
}
}
action.ts
export type ChangeNameAction = {
type: 'CHANGE_NAME',
name: string,
}
export function changeNameAction(name: string): ChangeNameAction {
return {
type: 'CHANGE_NAME',
name: name
}
}
It renders correctly:
But if I type anything in the text field, the value doesn't change.
I'm not sure why it doesn't work.
Here is a tiny but complete demo of this question: https://github.com/freewind-demos/typescript-react-redux-context-consumer-demo
ReactReduxContext is a React Context that contains the current Redux store.
The store reference never changes in normal operation, which means that anyone listening directly to the context in the manner you are doing will never see updates.
React Redux's connect() and useSelector both subscribe to the store and 'notify' React of the updates (usually by setting some state, which causes a re-render of the subscribed component).
You could implement your own primitive version of useSelector by doing this:
function useSelector(f) {
const store = React.useContext(ReactReduxContent);
const [state, setNextState] = React.useState(() => {
return f(store.getState());
});
React.useEffect(() => {
return store.listen(state => {
setNextState(f(store.getState()));
});
}, [f, store]);
return state;
}
React Context only propagates changes when the value passed to the provider changes, which is why you must subscribe to the store itself - as mentioned earlier, the store will never change in normal operation.

Resources