The first value set to "search term" through the "dispatcher" persists after any subsequent calls and I'm trying to figure out why that is or where the error is.
I've got a <ContextProvider /> where a state for "search term" is defined, and the value for the "search term" might change by an event that is triggered by the <ContextConsumer />, or nested <ContextConsumer /> component by a "dispatcher". I'm finding that the desired state is not found, after the call to the "reducer", even considering that the "state" change is not immediately.
For brevity, the Components or the code posted below was simplified to isolate the subject, so there might be a few typos like not declared variables (as I've removed chunks of code that is not related).
The Context Provider looks like:
import React from 'react'
export const POSTS_SEARCH_RESULTS = 'POSTS_SEARCH_RESULTS'
export const GlobalStateContext = React.createContext()
export const GlobalDispatchContext = React.createContext()
const initialState = {
posts: [],
searchTerm: ''
}
const reducer = (state, action) => {
switch (action.type) {
case POSTS_SEARCH_RESULTS: {
return {
...state,
posts: action.posts,
searchTerm: action.searchTerm
}
}
default:
throw new Error('Bad Action Type')
}
}
const GlobalContextProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState)
return (
<GlobalStateContext.Provider value={state}>
<GlobalDispatchContext.Provider value={dispatch}>
{children}
</GlobalDispatchContext.Provider>
</GlobalStateContext.Provider>
)
}
export default GlobalContextProvider
The Consumer looks like:
const Search = () => {
const state = useContext(GlobalStateContext)
const { searchTerm, posts } = state
useEffect(() => {
console.log('[debug] <Search />: searchTerm: ', searchTerm);
}, [searchTerm])
return (
<>
<LoadMoreScroll searchTerm={searchTerm} posts={posts} postCursor={postCursor} />
</>
)
}
export default Search
Following up is the nested Consumer Children Component. The useEffect has a dependency for searchTerm; This value is set through the "dispatcher" and get through the useContenxt in a Consumer.
dispatch({ type: POSTS_SEARCH_RESULTS, posts: postsCached, searchTerm: term })
And consumed like so:
const state = useContext(GlobalStateContext)
const { searchTerm, posts } = state
And passed to, for example <LoadMoreScroll searchTerm={searchTerm} />
So, what I have and it fails is:
const LoadMoreScroll = ({ searchTerm, posts, postCursor }) => {
const dispatch = useContext(GlobalDispatchContext)
const [postsCached, setPostsCached] = useState(posts)
const [loading, setLoading] = useState(false)
const refScroll = useRef(null)
const [first] = useState(POSTS_SEARCH_INITIAL_NUMBER)
const [after, setAfter] = useState(postCursor)
const [isVisible, setIsVisible] = useState(false)
const [term, setTerm] = useState(searchTerm)
useEffect(() => {
loadMore({ first, after, term })
}, [isVisible])
useEffect(() => {
dispatch({ type: POSTS_SEARCH_RESULTS, posts: postsCached, searchTerm })
}, [postsCached])
useEffect(() => {
setTerm(searchTerm)
const handler = _debounce(handleScroll, 1200)
window.addEventListener('scroll', handler)
return () => window.removeEventListener('scroll', handler)
}, [searchTerm])
const handleScroll = () => {
const offset = -(window.innerHeight * 0.1)
const top = refScroll.current.getBoundingClientRect().top
const isVisible = (top + offset) >= 0 && (top - offset) <= window.innerHeight
isVisible && setIsVisible(true)
}
const loadMore = async ({ first, after, term }) => {
if (loading) return
setLoading(true)
const result = await searchFor({
first,
after,
term
})
const nextPosts = result.data
setPostsCached([...postsCached, ...nextPosts])
setAfter(postCursor)
setLoading(false)
setIsVisible(false)
}
return (
<div ref={refScroll} className={style.loaderContainer}>
{ loading && <Loader /> }
</div>
)
}
export default LoadMoreScroll
The expected result is to have <LoadMoreScroll />'s to pass to the "loadMore" function the latest value of "searchTerm" assigned by the "dispatcher", which fails. What it does instead is that it consumes the "initial value" from a first call to the "dispatcher". This is after the initial call to the "dispatcher" any subsequent "dispatcher" call:
dispatch({ type: POSTS_SEARCH_RESULTS, posts: postsCached, searchTerm: term })
That should update the Context "searchTerm", fails to do. In the source code above, the loadmore holds the initial value that was set!
Separate example the has a similar logic, works without any issues ( https://codesandbox.io/s/trusting-booth-1w40e?fontsize=14&hidenavigation=1&theme=dark )
Hope to update the issue above with a solution soon, in case somebody spots the issue, please let me know!
The codesandbox link works, but doesn't seem to be using the same pattern as the code above when it comes to creating and using context.
In the provided code you have created two separate providers. One has a value of state and one has a value of dispatch.
<GlobalStateContext.Provider value={state}>
<GlobalDispatchContext.Provider value={dispatch}>
The codesandbox however is using both state and dispatch within the same provider.
<Application.Provider value={{ state, dispatch }}>
Also it seems that GlobalContextProvider is exported, but I'm not sure if it is used to wrap any consumers.
Since there is a separation of dispatch and state, I am going to use this for my proposed solution.
The implementation seems correct, but in my opinion you could take this a step further and create two custom hooks, that expose only one way to provide the context value and only one way to consume it.
import React from "react";
export const POSTS_SEARCH_RESULTS = "POSTS_SEARCH_RESULTS";
//
// notice that we don't need to export these anymore as we are going to be
//
// using them in our custom hooks useGlobalState and useGlobalDispatch
//
//
const GlobalStateContext = React.createContext();
const GlobalDispatchContext = React.createContext();
const initialState = {
posts: [],
searchTerm: "",
};
const reducer = (state, action) => {
switch (action.type) {
case POSTS_SEARCH_RESULTS: {
return {
...state,
posts: action.posts,
searchTerm: action.searchTerm
};
}
default:
throw new Error("Bad Action Type");
}
};
const GlobalContextProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState);
return (
<GlobalStateContext.Provider value={state}>
<GlobalDispatchContext.Provider value={dispatch}>
{children}
</GlobalDispatchContext.Provider>
</GlobalStateContext.Provider>
);
};
// If any of these hooks is not being called within a function component
// that is rendered within the `GlobalContextProvider`,
// we throw an error
const useGlobalState = () => {
const context = React.useContext(GlobalStateContext);
if (context === undefined) {
throw new Error(
"useGlobalState must be used within a GlobalContextProvider"
);
}
return context;
};
const useGlobalDispatch = () => {
const context = React.useContext(GlobalDispatchContext);
if (context === undefined) {
throw new Error(
"useGlobalDispatch must be used within a GlobalContextProvider"
);
}
return context;
};
// We only export the custom hooks for state and dispatch
// and of course our`GlobalContextProvider`, which we are
// going to wrap any part of our app that
// needs to make use of this state
export { GlobalContextProvider, useGlobalState, useGlobalDispatch };
All I've added here is a couple of custom hooks that expose each of the contexts, i.e GlobalStateContext and GlobalDispatchContext and export them along with the GlobalContextProvider.
If we wanted to make this globally available throughout the app, we could wrap the GlobalContextProvider around the App component.
function App() {
return (
<div className="App">
<Search />
</div>
);
}
// If you forget to wrap the consumer with your provider, the custom hook will
// throw an error letting you know that the hook is not being called
// within a function component that is rendered within the
// GlobalContextProvider as it's supposed to
const AppContainer = () => (
<GlobalContextProvider>
<App />
</GlobalContextProvider>
);
export default AppContainer;
If you want to either use the state in any part of your app, or dispatch any action, you will need to import the relevant custom hook created earlier.
In your Search component this would look like the example below:
import { useGlobalState, useGlobalDispatch } from "./Store";
const Search = () => {
// Since we are doing this in our custom hook that is not needed anymore
// const state = useContext(GlobalStateContext)
// if you need to dispatch any actions you can
// import the useGlobalDispatch hook and use it like so:
// const dispatch = useGlobalDispatch();
const state = useGlobalState();
const { searchTerm, posts } = state
useEffect(() => {
console.log('[debug] <Search />: searchTerm: ', searchTerm);
}, [searchTerm])
return (
<>
<LoadMoreScroll searchTerm={searchTerm} posts={posts} postCursor={postCursor} />
</>
)
}
export default Search
Since there were a few parts missing in the codesandbox provided in the question, I've refactored it to a simplified working version of this concept here that hopefully will help solve your issue.
I've also found this article quite helpful when I had problems with Context API and hooks.
It is following that same pattern, I've been using this in production and have been quite happy with the results.
Hope that helps :)
Related
I'm fairly new to the context API and react hooks beyond useState and useEffect so please bare with me.
I'm trying to create a custom useGet hook that I can use to GET some data from the backend then store this using the context API, so that if I useGet again elsewhere in the app with the same context, it can first check to see if the data has been retrieved and save some time and resources having to do another GET request. I'm trying to write it to be used generally with various different data and context.
I've got most of it working up until I come to try and dispatch the data to useReducer state and then I get the error:
Hooks can only be called inside the body of a function component.
I know I'm probably breaking the rules of hooks with my call to dispatch, but I don't understand why only one of my calls throws the error, or how to fix it to do what I need. Any help would be greatly appreciated.
commandsContext.js
import React, { useReducer, useContext } from "react";
const CommandsState = React.createContext({});
const CommandsDispatch = React.createContext(null);
function CommandsContextProvider({ children }) {
const [state, dispatch] = useReducer({});
return (
<CommandsState.Provider value={state}>
<CommandsDispatch.Provider value={dispatch}>
{children}
</CommandsDispatch.Provider>
</CommandsState.Provider>
);
}
function useCommandsState() {
const context = useContext(CommandsState);
if (context === undefined) {
throw new Error("Must be within CommandsState.Provider");
}
return context;
}
function useCommandsDispatch() {
const context = useContext(CommandsDispatch);
if (context === undefined) {
throw new Error("Must be within CommandsDispatch.Provider");
}
return context;
}
export { CommandsContextProvider, useCommandsState, useCommandsDispatch };
useGet.js
import { API } from "aws-amplify";
import { useRef, useEffect, useReducer } from "react";
export default function useGet(url, useContextState, useContextDispatch) {
const stateRef = useRef(useContextState);
const dispatchRef = useRef(useContextDispatch);
const initialState = {
status: "idle",
error: null,
data: [],
};
const [state, dispatch] = useReducer((state, action) => {
switch (action.type) {
case "FETCHING":
return { ...initialState, status: "fetching" };
case "FETCHED":
return { ...initialState, status: "fetched", data: action.payload };
case "ERROR":
return { ...initialState, status: "error", error: action.payload };
default:
return state;
}
}, initialState);
useEffect(() => {
if (!url) return;
const getData = async () => {
dispatch({ type: "FETCHING" });
if (stateRef.current[url]) { // < Why doesn't this also cause an error
const data = stateRef.current[url];
dispatch({ type: "FETCHED", payload: data });
} else {
try {
const response = await API.get("talkbackBE", url);
dispatchRef.current({ url: response }); // < This causes the error
dispatch({ type: "FETCHED", payload: response });
} catch (error) {
dispatch({ type: "ERROR", payload: error.message });
}
}
};
getData();
}, [url]);
return state;
}
EDIT --
useCommandsState and useCommandsDispatch are imported to this component where I call useGet passing the down.
import {
useCommandsState,
useCommandsDispatch,
} from "../../contexts/commandsContext.js";
export default function General({ userId }) {
const commands = useGet(
"/commands?userId=" + userId,
useCommandsState,
useCommandsDispatch
);
Why am I only getting an error for the dispatchRef.current, and not the stateRef.current, When they both do exactly the same thing for the state/dispatch of useReducer?
How can I refactor this to solve my problem? To summarise, I need to be able to call useGet in two or more places for each context with the first time it's called the data being stored in the context passed.
Here are various links to things I have been reading, which have helped me to get this far.
How to combine custom hook for data fetching and context?
Updating useReducer 'state' using useEffect
Accessing context from useEffect
https://reactjs.org/warnings/invalid-hook-call-warning.html
I think your problem is because you are using useRef instead of state for storing state. If you useRef for storing state you need to manually tell react to update.
I personally would not use reducer and just stick to the hooks you are familiar with as they fulfill your current requirements. I also think they are the best tools for this simple task and are easier to follow.
Code
useGetFromApi.js
This is a generalized and reusable hook - can be used inside and outside of the context
export const useGetFromApi = (url) => {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!url) return;
const getData = async () => {
try {
setLoading(true);
setData(await API.get('talkbackBE', url));
} catch ({ message }) {
setError(message);
} finally {
setLoading(false); // always set loading to false
}
};
getData();
}, [url]);
return { data, error, loading };
};
dataProvider.js
export const DataContext = createContext(null);
export const DataProvider = ({ children, url}) => {
const { data, error, loading } = useGetFromApi(url);
return (
<DataContext.Provider value={{ data, error, loading }}>
{children}
</DataContext.Provider>
);
};
useGet.js
Don't need to check if context is undefined - React will let you know
export const useGet = () => useContext(DataContext);
Usage
Most parent wrapping component that needs access to data. This level doesn't have access to the data - only it's children do!
const PageorLayout = ({children}) => (
<DataProvider url="">{children}</DataProvider>
)
A page or component that is nested inside of the context
const NestedPageorComponent = () => {
const {data, error, loading } = useGet();
if(error) return 'error';
if(loading) return 'loading';
return <></>;
}
Hopefully this is helpful!
Note I wrote most of this on Stack in the editor so I was unable to test the code but it should provide a solid example
I'm having issue where hook is being used in multiple files and it is being called twice for useEffect before the 1st one's async method finish (which should block the 2nd hook call, but it's not). See below 2 scenarios:
Stack Navigator
const { context, state } = useLobby(); // Hook is called here 1st, which will do the initial render and checks
return (
<LobbyContext.Provider value={context}>
<LobbyStack.Navigator>
{state.roomId
? <LobbyStack.Screen name="Lobby" component={LobbyScreen} />
: <LobbyStack.Screen name="Queue" component={QueueScreen} />
}
</LobbyStack.Navigator>
</LobbyContext.Provider>
)
Lobby Hooks
export const useLobby = () => {
const [state, dispatch] = React.useReducer(...)
//
// Scenario 1
// This get called twice (adds user to room twice)
//
React.useEffect(() => {
if (!state.isActive) assignRoom();
}, [state.isActive])
const assignRoom = async () => {
// dispatch room id
}
const context = React.useMemo(() => ({
join: () => { assignRoom(); }
})
}
Queue Screen
const { context, state } = useLobby(); // Hook is called here 2nd right after checking state from stack navigator
//
// Scenario 2
// Only does it once, however after state is changed to active
// the stack navigator didn't get re-render like it did in Scenario 1
//
React.useEffect(() => {
roomLobby.join();
}, []);
return (
...
{state.isActive
? "Show the room Id"
: "Check again"
...
)
In scenario 1, I guess while 1st hook is called and useEffect is doing async to add user to the room and set active to true. Meanwhile the conditional render part is moving straight to Queue screen which calls the hook again and doing the useEffect (since 1st haven't finished and isActive is still false).
How can I properly setup useReducer and useMemo so that it renders the screen base on the state.
Edited codes based on the answer
/* LobbyProvider */
const LobbyContext = React.createContext();
const lobbyReducer = (state, action) => {
switch (action.type) {
case 'SET_LOBBY':
return {
...state,
isActive: action.active,
lobby: action.lobby
};
case 'SET_ROOM':
return {
...state,
isQueued: action.queue,
roomId: action.roomId,
};
default:
return state;
}
}
const LobbyProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(lobbyReducer, initialState);
React.useEffect(() => {
console.log("Provider:", state)
if (!state.isActive) joinRoom();
}, [])
// Using Firebase functions
const joinRoom = async () => {
try {
const response = await functions().httpsCallable('getActiveLobby')();
if (response) {
dispatch({ type: 'SET_LOBBY', active: true, lobby: response.data })
const room = await functions().httpsCallable('assignRoom')({ id: response.data.id });
dispatch({ type: 'SET_ROOM', queue: false, roomId: room.data.id })
}
} catch (e) {
console.error(e);
}
}
return (
<LobbyContext.Provider value={{state, dispatch}}>
{ children }
</LobbyContext.Provider>
)
}
/* StackNavigator */
const {state} = React.useContext(LobbyContext);
return (
<LobbyProvider>
// same as above <LobbyStack.Navigator>
// state doesn't seem to be updated here or to re-render
</LobbyProvider>
);
/* Queue Screen */
const {state} = React.useContext(LobbyContext);
// accessing state.isActive to do some conditional rendering
// which roomId does get rendered after dispatch
You must note that a custom hook will create a new instance of state everytime its called.
For example, you call the hook in StackNavigator component and then again in QueueScreen, so 2 different useReducers will be invoked instead of them sharing the states.
You should instead use useReducer in StackNavigator's parent and then utilize that as context within useLobby hook
const LobbyStateContext = React.createContext();
const Component = ({children}) => {
const [state, dispatch] = React.useReducer(...)
return (
<LobbyStateContext.Provider value={[state, dispatch]]>
{children}
</LobbyStateContext>
)
}
and use it like
<Component>
<StackNavigator />
</Component>
useLobby will then look like
export const useLobby = () => {
const [state, dispatch] = React.useContext(LobbyStateContext)
const assignRoom = async () => {
// dispatch room id
}
const context = React.useMemo(() => ({
join: () => { assignRoom(); }
})
return { context, assignRoom, state};
}
StackNavigator will utilize useLobby and have the useEFfect logic
const { context, state, assignRoom } = useLobby();
React.useEffect(() => {
if (!state.isActive) assignRoom();
}, [state.isActive])
return (
<LobbyContext.Provider value={context}>
<LobbyStack.Navigator>
{state.roomId
? <LobbyStack.Screen name="Lobby" component={LobbyScreen} />
: <LobbyStack.Screen name="Queue" component={QueueScreen} />
}
</LobbyStack.Navigator>
</LobbyContext.Provider>
)
I've created a react function component for the context as follows:
const ItemContext = createContext()
const ItemProvider = (props) => {
const [item, setItem] = useState(null)
const findById = (args = {}) => {
fetch('http://....', { method: 'POST' })
.then((newItem) => {
setItem(newItem)
})
}
let value = {
actions: {
findById
},
state: {
item
}
}
return <ItemContext.Provider value={value}>
{props.children}
</ItemContext.Provider>
}
In this way, I have my context that handles all the API calls and stores the state for that item. (Similar to redux and others)
Then in my child component further down the line that uses the above context...
const smallComponent = () =>{
const {id } = useParams()
const itemContext = useContext(ItemContext)
useEffect(()=>{
itemContext.actions.findById(id)
},[id])
return <div>info here</div>
}
So the component should do an API call on change of id. But I'm getting this error in the console:
React Hook useEffect has a missing dependency: 'itemContext.actions'. Either include it or remove the dependency array react-hooks/exhaustive-deps
If I add it in the dependency array though, I get a never ending loop of API calls on my server. So I'm not sure what to do. Or if I'm going at this the wrong way. Thanks.
=== UPDATE ====
Here is a jsfiddle to try it out: https://jsfiddle.net/zx5t76w2/
(FYI I realized the warning is not in the console as it's not linting)
You could just utilize useCallback for your fetch method, which returns a memoized function:
const findById = useCallback((args = {}) => {
fetch("http://....", { method: "POST" }).then(newItem => {
setItem(newItem);
});
}, []);
...and put it in the useEffect:
...
const { actions, state } = useContext(ItemContext)
useEffect(() => {
actions.findById(id)
}, [id, actions.findById])
...
Working example: https://jsfiddle.net/6r5jx1h7/1/
Your problem is related to useEffect calling your custom hook again and again, because it's a normal function that React is not "saving" throughout the renders.
UPDATE
My initial answer fixed the infinite loop.
Your problem was also related to the way you use the context, as it recreates the domain objects of your context (actions, state, ..) again and again (See caveats in the official documentation).
Here is your example in Kent C. Dodds' wonderful way of splitting up context into state and dispatch, which I can't recommend enough. This will fix your infinite loop and provides a cleaner structure of the context usage. Note that I'm still using useCallback for the fetch function based on my original answer:
Complete Codesandbox https://codesandbox.io/s/fancy-sea-bw70b
App.js
import React, { useEffect, useCallback } from "react";
import "./styles.css";
import { useItemState, ItemProvider, useItemDispatch } from "./item-context";
const SmallComponent = () => {
const id = 5;
const { username } = useItemState();
const dispatch = useItemDispatch();
const fetchUsername = useCallback(async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/" + id
);
const user = await response.json();
dispatch({ type: "setUsername", usernameUpdated: user.name });
}, [dispatch]);
useEffect(() => {
fetchUsername();
}, [fetchUsername]);
return (
<div>
<h4>Username from fetch:</h4>
<p>{username || "not set"}</p>
</div>
);
};
export default function App() {
return (
<div className="App">
<ItemProvider>
<SmallComponent />
</ItemProvider>
</div>
);
}
item-context.js
import React from "react";
const ItemStateContext = React.createContext();
const ItemDispatchContext = React.createContext();
function itemReducer(state, action) {
switch (action.type) {
case "setUsername": {
return { ...state, username: action.usernameUpdated };
}
default: {
throw new Error(`Unhandled action type: ${action.type}`);
}
}
}
function ItemProvider({ children }) {
const [state, dispatch] = React.useReducer(itemReducer, {
username: "initial username"
});
return (
<ItemStateContext.Provider value={state}>
<ItemDispatchContext.Provider value={dispatch}>
{children}
</ItemDispatchContext.Provider>
</ItemStateContext.Provider>
);
}
function useItemState() {
const context = React.useContext(ItemStateContext);
if (context === undefined) {
throw new Error("useItemState must be used within a CountProvider");
}
return context;
}
function useItemDispatch() {
const context = React.useContext(ItemDispatchContext);
if (context === undefined) {
throw new Error("useItemDispatch must be used within a CountProvider");
}
return context;
}
export { ItemProvider, useItemState, useItemDispatch };
Both of these blog posts helped me a lot when I started using context with hooks initially:
https://kentcdodds.com/blog/application-state-management-with-react
https://kentcdodds.com/blog/how-to-use-react-context-effectively
OK, I didn't want to write an answer as Bennett basically gave you the fix, but I think it is missing the part in the component, so here you go:
const ItemProvider = ({ children }) => {
const [item, setItem] = useState(null)
const findById = useCallback((args = {}) => {
fetch('http://....', { method: 'POST' }).then((newItem) => setItem(newItem))
}, []);
return (
<ItemContext.Provider value={{ actions: { findById }, state: { item } }}>
{children}
</ItemContext.Provider>
)
}
const smallComponent = () => {
const { id } = useParams()
const { actions } = useContext(ItemContext)
useEffect(() => {
itemContext.actions.findById(id)
}, [actions.findById, id])
return <div>info here</div>
}
Extended from the comments, here's the working JSFiddle
I have a custom hook to fetch data on form submit
export const getIssues = ({ user, repo }) => {
const [issues, setIssues] = useState([]);
const handleInputChange = (e) => {
e.preventDefault();
axios.get(`https://api.github.com/repos/${user}/${repo}/issues`)
.then((response) => {
setIssues(response.data);
})
.catch((err) => console.log(err));
};
return {
issues,
onSubmit: handleInputChange,
};
};
In my component I call it like this
const response = getIssues({ user: user.value, repo: repo.value })
return (
<form className={css['search-form']} {...response}>...</form>
)
The problem is that I want to get my issues value from the hook in another component. For that I wanted to use Context. But I have no idea how to do it.
I could call this function and pass it to Provider, but I can't call it without arguments. So I kind of stuck.
All the help will be much appreciated.
You are right by saying you need React.Context to handle this situation.
You need to wrap your components into this context.
import React from "react";
const IssuesStateContext = React.createContext();
const IssuesDispatchContext = React.createContext();
function issuesReducer(state, action) {
switch (action.type) {
case "setIssues": {
return [...action.payload];
}
default: {
throw new Error(`Unhandled action type: ${action.type}`);
}
}
}
function IssuesProvider({ children }) {
const [state, dispatch] = React.useReducer(issuesReducer, []);
return (
<IssuesStateContext.Provider value={state}>
<IssuesDispatchContext.Provider value={dispatch}>
{children}
</IssuesDispatchContext.Provider>
</IssuesStateContext.Provider>
);
}
function useIssuesState() {
const context = React.useContext(IssuesStateContext);
if (context === undefined) {
throw new Error("useIssuesState must be used within a IssuesProvider");
}
return context;
}
function useIssuesDispatch() {
const context = React.useContext(IssuesDispatchContext);
if (context === undefined) {
throw new Error("useIssuesDispatch must be used within a IssuesProvider");
}
return context;
}
export { IssuesProvider, useIssuesState, useIssuesDispatch };
By using this separation in context you will be able to set issues coming from github in one component and render them in a completely different one.
Example:
App.js
ReactDOM.render(
<IssuesProvider>
<Component1 />
<Component2 />
</IssuesProvider>
)
Component 1
import React from 'react'
import { useIssuesDispatch } from './issues-context'
function Component1() {
const dispatch = useIssuesDispatch()
// fetch issues
// .then dispatch({ type: 'setIssues', payload: response })
// render
}
Component 2
import React from 'react'
import { useIssuesState } from './issues-context'
function Component2() {
const issues = useIssuesState()
// if issues.length > 0 ? render : null
}
You can write a Issues context provider that will provide {issues,useIssues} where issues are the issues and useIssues is a function that takes {user,repo}.
export const Issues = React.createContext();
export default ({ children }) => {
const [issues, setIssues] = useState([]);
const useIssues = ({ user, repo }) => {
useEffect(() => {
axios
.get(
`https://api.github.com/repos/${user}/${repo}/issues`
)
.then(response => {
setIssues(response.data);
})
.catch(err => console.log(err));
}, [user, repo]);
return issues;
};
return (
<Issues.Provider value={{ issues, useIssues }}>
{children}
</Issues.Provider>
);
};
The component that has all the components that need issues can import this issues provider:
import IssuesProvider from './IssuesProvider';
export default () => (
<IssuesProvider>
<ComponentThatNeedsIssues />
<ComponentThatSetsAndGetsIssues />
</IssuesProvider>
);
For a component that needs to set issues you can get useIssues from context:
const { useIssues } = useContext(Issues);
const issues = useIssues({user,repo});
For a component that only needs issues:
const { issues } = useContext(Issues);
To see it all work together there is a codepen here
I'm wondering what the recommended best practice is for manipulating and exposing the new React Context.
The easiest way to manipulate context state seems to be to just attach a function to the context that either dispatches (usereducer) or setstate (useState) to change its internal value once called.
export const TodosProvider: React.FC<any> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, null, init);
return (
<Context.Provider
value={{
todos: state.todos,
fetchTodos: async id => {
const todos = await getTodos(id);
console.log(id);
dispatch({ type: "SET_TODOS", payload: todos });
}
}}
>
{children}
</Context.Provider>
);
};
export const Todos = id => {
const { todos, fetchTodos } = useContext(Context);
useEffect(() => {
if (fetchTodos) fetchTodos(id);
}, [fetchTodos]);
return (
<div>
<pre>{JSON.stringify(todos)}</pre>
</div>
);
};
I was however told exposing and using the react context object directly is probably not a good idea, and was told to wrap it inside a hook instead.
export const TodosProvider: React.FC<any> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, null, init);
return (
<Context.Provider
value={{
dispatch,
state
}}
>
{children}
</Context.Provider>
);
};
const useTodos = () => {
const { state, dispatch } = useContext(Context);
const [actionCreators, setActionCreators] = useState(null);
useEffect(() => {
setActionCreators({
fetchTodos: async id => {
const todos = await getTodos(id);
console.log(id);
dispatch({ type: "SET_TODOS", payload: todos });
}
});
}, []);
return {
...state,
...actionCreators
};
};
export const Todos = ({ id }) => {
const { todos, fetchTodos } = useTodos();
useEffect(() => {
if (fetchTodos && id) fetchTodos(id);
}, [fetchTodos]);
return (
<div>
<pre>{JSON.stringify(todos)}</pre>
</div>
);
};
I have made running code examples for both variants here: https://codesandbox.io/s/mzxrjz0v78?fontsize=14
So now I'm a little confused as to which of the 2 ways is the right way to do it?
There is absolute no problem with using useContext directly in a component. It however forces the component which has to use the context value to know what context to use.
If you have multiple components in the App where you want to make use of TodoProvider context or you have multiple Contexts within your app , you simplify it a little with a custom hook
Also one more thing that you must consider when using context is that you shouldn't be creating a new object on each render otherwise all components that are using context will re-render even though nothing would have changed. To do that you can make use of useMemo hook
const Context = React.createContext<{ todos: any; fetchTodos: any }>(undefined);
export const TodosProvider: React.FC<any> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, null, init);
const context = useMemo(() => {
return {
todos: state.todos,
fetchTodos: async id => {
const todos = await getTodos(id);
console.log(id);
dispatch({ type: "SET_TODOS", payload: todos });
}
};
}, [state.todos, getTodos]);
return <Context.Provider value={context}>{children}</Context.Provider>;
};
const getTodos = async id => {
console.log(id);
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos/" + id
);
return await response.json();
};
export const useTodos = () => {
const todoContext = useContext(Context);
return todoContext;
};
export const Todos = ({ id }) => {
const { todos, fetchTodos } = useTodos();
useEffect(() => {
if (fetchTodos) fetchTodos(id);
}, [id]);
return (
<div>
<pre>{JSON.stringify(todos)}</pre>
</div>
);
};
Working demo
EDIT:
Since getTodos is just a function that cannot change, does it make
sense to use that as update argument in useMemo?
It makes sense to pass getTodos to dependency array in useMemo if getTodos method is changing and is called within the functional component. Often you would memoize the method using useCallback so that its not created on every render but only if any of its dependency from enclosing scope changes to update the dependency within its lexical scope. Now in such a case you would need to pass it as a parameter to the dependency array.
However in your case, you can omit it.
Also how would you handle an initial effect. Say if you were to call
`getTodosĀ“ in useEffect hook when provider mounts? Could you memorize
that call as well?
You would simply have an effect within Provider that is called on initial mount
export const TodosProvider: React.FC<any> = ({ children }) => {
const [state, dispatch] = useReducer(reducer, null, init);
const context = useMemo(() => {
return {
todos: state.todos,
fetchTodos: async id => {
const todos = await getTodos(id);
console.log(id);
dispatch({ type: "SET_TODOS", payload: todos });
}
};
}, [state.todos]);
useEffect(() => {
getTodos();
}, [])
return <Context.Provider value={context}>{children}</Context.Provider>;
};
I don't think there's an official answer, so let's try to use some common sense here. I find perfectly fine to use useContext directly, I don't know who told you not to, perhaps HE/SHE should have pointed for official docs. Why would the React team create that hook if it wasn't supposed to be used? :)
I can understand, however, trying to avoid creating a huge object as the value in the Context.Provider, one that mixes state with functions that manipulate it, possibly with async effects like your example.
However, in your refactor, you introduced a very weird and absolutely unnecessary useState for the action creator that you simply had defined inline in your first approach. It seems to me you were looking for useCallback instead. So, why don't you mix both like this?
const useTodos = () => {
const { state, dispatch } = useContext(Context);
const fetchTodos = useCallback(async id => {
const todos = await getTodos(id)
dispatch({ type: 'SAVE_TODOS', payload: todos })
}, [dispatch])
return {
...state,
fetchTodos
};
}
Your calling code doesn't need that weird check to verify that fetchTodos indeed exists.
export const Todos = id => {
const { todos, fetchTodos } = useContext(Context);
useEffect(() => {
fetchTodos()
}, []);
return (
<div>
<pre>{JSON.stringify(todos)}</pre>
</div>
);
};
Finally, unless you actually need to use this todos + fetchTodos combo from more components down the tree from Todos, which you didn't explictly stated in your question, I think using Context is complicating matters when they're not needed. Remove the extra layer of indirection and call useReducer directly in your useTodos.
It may not be the case here, but I find people are mixing a lot of things in their head and turning something simple into something complicated (like Redux = Context + useReducer).
Hope it helps!