When a simple React context gets out of hand.
Jeremy Ward ๐๐ค
Posted on January 25, 2021
TL;DR:
- Sometimes what you think is a K.I.S.S. solution turns into Frankenstein.
- If you find yourself wanting to use a
useEffect
inside a React context, think twice. - More importantly, be careful with
useEffects
that depend on global state. - Kent C Dodds has some clean ideas about setting up the React Context API.
- I will prolly default to a
useReducer
in my "app" contexts from now on.
Let's start simple.
My team started a new React app and we wanted see what it would be like to use the React Context API, simple useState
. We also wanted to treat each context as "boxes" of similar data.
Let's assume that our app has grown to need 2 contexts:
- 1 for "Auth"
- 1 for the "Timeline" [for lack of better naming]
const AuthContext = React.createContext();
const AuthContextProvider = ({ children }) => {
const [user, setUser] = useState();
const [isLoggedIn, setIsLoggedIn] = useState();
const state = { user, isLoggedIn };
return (
<AuthContext.Provider value={{ state, setUser, setIsLoggedIn }}>
{children}
</AuthContext.Provider>
);
};
The AuthContext
contains state associated with authentication. When a user signs in, setIsLoggedIn(true) & setUser({email, username}) functions are both called. This will change the state of the AuthContext
and can trickle through the app.
const TimelineContext = React.createContext();
const TimelineContextProvider = ({ children }) => {
const [posts, setPosts] = useState([]);
// For the purposes of this blog, selectedPost will be used to display
// the "show page"
const [selectedPost, setSelectedPost] = useState(null);
// And let's imagine we want to do the same thing for a comment.
const [selectedComment, setSelectedComment] = useState(null);
const state = { posts, selectedPost, selectedComment };
return (
<TimelineContext.Provider
value={{ state, setPosts, setSelectedPost, setSelectedComment }}
>
{children}
</TimelineContext.Provider>
);
};
The TimelineContext
will maintain the state for our timeline including a list of posts
, a selectedPost
, and a selectedComment
.
These are pretty simple, right?
One issue with this that immediately pops out is the return value of each context. Currently, we can see as we add new state the return value grow pretty quickly.
Let's go ahead and solve that in the TimelineContext
.
const TimelineContextProvider = ({ children }) => {
const [posts, setPosts] = useState([]);
const [selectedPost, setSelectedPost] = useState(null)
const [selectedComment, setSelectedComment] = useState(null)
const state = { posts, selectedPost, selectedComment };
const actions = { setPosts, setSelectedPost, setSelectedComment }
return (
<TimelineContext.Provider value={{ state, actions}}>
{children}
</TimelineContext.Provider>
);
};
Ok. That helps a bit. We have constrained the return object to state
& actions
.
Another annoyance would be if this context grows in size. The more useStates
we add, the harder it could be to manage. This was the idea of having multiple contexts. We can have a clear separation of concerns.
NEW REQUIREMENT!
Now we want to set a selected post and comment within our application. If the comment is dependent on the post, we will also need to nullify the selectedComment
when a new post is selected.
This is fairly simple. We can just throw in a useEffect
and boom.
const TimelineContextProvider = ({ children }) => {
const [posts, setPosts] = useState([]);
const [selectedPost, setSelectedPost] = useState(null)
const [selectedComment, setSelectedComment] = useState(null)
const state = { posts, selectedPost, selectedComment };
const actions = { setPosts, setSelectedPost, setSelectedComment }
useEffect(() => {
setSelectedComment(null)
}, [selectedPost])
return (
<TimelineContext.Provider value={{ state, actions}}>
{children}
</TimelineContext.Provider>
);
};
More Modification!!!
Now let's say for testing purposes we want to add initial{SelectedPost and SelectedComment}. Stupid simple. Or is it?
The way we currently have it set up, the useEffect
will set our initialSelectedComment
to null
on the first render. OOOO no a side useEffect!!!
So our context then turns into:
const TimelineContextProvider = ({
initialSelectedPost,
initialSelectedComment,
children
}) => {
const [posts, setPosts] = useState([]);
const [selectedPost, setSelectedPost] = useState(initialSelectedPost);
const [selectedComment, setSelectedComment] = useState(
initialSelectedComment
);
const state = { posts, selectedPost, selectedComment };
const actions = { setPosts, setSelectedPost, setSelectedComment };
useEffect(() => {
if (initialSelectedPost != initialSelectedComment) {
setSelectedComment(null);
}
}, [selectedPost]);
return (
<TimelineContext.Provider value={{ state, actions }}>
{children}
</TimelineContext.Provider>
);
};
This may not be a huge issue, but it will cause us to have to think about any consequences that may occur just from changing state.
Single Source of Global Truth
One gripe from the team has been "well which use{X}Context do I use in the component?". Both the AuthContext
and TimelineContext
are part of the global state so one solution would be to just combine them, and separate the domains inside the state object. Let's start by solving that issue.
const AppContextProvider = ({
initialSelectedPost,
initialSelectedComment,
children
}) => {
const [user, setUser] = useState();
const [isLoggedIn, setIsLoggedIn] = useState();
const [posts, setPosts] = useState([]);
const [selectedPost, setSelectedPost] = useState(initialSelectedPost);
const [selectedComment, setSelectedComment] = useState(
initialSelectedComment
);
const state = {
auth: { user, isLoggedIn },
timeline: { posts, selectedPost, selectedComment }
};
const actions = {
setUser,
setIsLoggedIn,
setPosts,
setSelectedPost,
setSelectedComment
};
useEffect(() => {
if (initialSelectedPost != initialSelectedComment) {
setSelectedComment(null);
}
}, [selectedPost]);
return (
<AppContext.Provider value={{ state, actions }}>
{children}
</AppContext.Provider>
);
};
Not a huge win IMO, but now the team is happier.
Revelio Side Effects
After working with React hooks for a year, I've come to the conclusion that useEffect
in a context is probably a bad idea. (I'd love to see examples where you've made this work BTW).
A more concrete rule that I've landed on is that we should not have a useEffect
in our app that relies on global state. I kind of see this a sharp knife that could easily poke your eye out. It raises the barrier to work on a project for people that don't work in the frontend day in and day out. Even for someone working in the codebase, it's something they always have to keep in the back of their mind. "If I change {X}, this callback will run, and do I need to modify it?".
My solution to this is to always (well prolly 95% of the time) use useReducer
in global state and to never have a useEffect
depend on a piece of global state.
Let's go!
Initial State
First, we will start with our app's initial state.
const initialState = {
auth: { user: null, isLoggedIn: false },
timeline: { posts: [], selectedPost: null, selectedComment: null }
};
Well, that was easy enough! Defining our initial state lets us see all of our global state at a glance. Any time we want to add something to our global state, we can start by adding a sensible default to our initialState
object. For example, isLoggedIn
is initially false, and posts
is initially an empty array.
Reducery, my dear Watson
My favorite part of the reducer pattern is you can think of each action in your reducer as single interactions with your app. These interactions can either be network requests or UserEvents. When setting up an action, I ask "What happens to the state when {X} occurs". Then, you just dispatch that action with the correct payload and boom boom boom. Done! Now, if the same interaction occurs in 2 places, you don't have to open the other component and remember the logic; you just dispatch the action.
For the auth
part of our context, we have 2 interactions: sign in and logout.
Let's take a look at the code for this.
const ActionTypes = {
SET_USER: "set-user",
LOGOUT_USER: "logout-user",
}
const reducer = (state, action) => {
switch (action.type) {
case ActionTypes.SET_USER: {
return {
...state,
auth: { ...state.auth, user: action.payload, isLoggedIn: true }
};
}
case ActionTypes.LOGOUT_USER: {
return {
...state,
auth: { ...state.auth, user: null, isLoggedIn: false }
};
}
...
}
};
Wow, that's K.I.S.S. :D
Now we don't have to remember to call setUser
and setIsLoggedIn
, we just dispatch the corresponding action for the given interaction.
Next up, let's add actions for the timeline
state.
const ActionTypes = {
...,
ADD_POSTS: "add-posts",
SELECT_POST: "select-post",
SELECT_COMMENT: "select-comment"
};
const reducer = (state, action) => {
switch (action.type) {
...,
case ActionTypes.ADD_POSTS: {
return {
...state,
timeline: {
...state.timeline,
posts: [...state.timeline.posts, ...action.payload]
}
};
}
case ActionTypes.SELECT_POST: {
return {
...state,
timeline: {
...state.timeline,
selectedPost: action.payload,
selectedComment: null
}
};
}
case ActionTypes.SELECT_COMMENT: {
return {
...state,
timeline: {
...state.timeline,
selectedComment: action.payload
}
};
}
...,
}
};
You may not have realized it, but the SELECT_POST
action solves the useEffect side effect issue! If you remember, we had a useEffect
in our original context that would nullify the selectedComment
when the selectedPost
changes. Now, we can set an initialSelectedPost
& initialSelectedComment
without worrying about the useEffect
firing off; eliminating the need for an if
state just for testing purposes.
The New Context
The last piece of the puzzle is providing our new reducer to our app via a React Context.
const AppProvider = ({ initialState, reducer, children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};
Well, that's a lot cleaner. My team works in a Rails monolith which is why I've decided to have initialState
and the reducer
be props for the AppProvider
. This approach allows us to use the same provider for any React app that we decide to create.
Conclusion
Currently, this is my favorite way to [with some extra magic I'll blog about later] manage global state in a React app.
- No added dependencies.
- No side effects on global state that have to be memorized.
- Each interation is mapped to a single encapsulated action.
Putting it all together.
const initialState = {
auth: { user: null, isLoggedIn: false },
timeline: { posts: [], selectedPost: null, selectedComment: null }
};
const ActionTypes = {
SET_USER: "set-user",
LOGOUT_USER: "logout-user",
ADD_POSTS: "add-posts",
SELECT_POST: "select-post",
SELECT_COMMENT: "select-comment"
};
const reducer = (state, action) => {
switch (action.type) {
case ActionTypes.SET_USER: {
return {
...state,
auth: { ...state.auth, user: action.payload, isLoggedIn: true }
};
}
case ActionTypes.LOGOUT_USER: {
return {
...state,
auth: { ...state.auth, user: null, isLoggedIn: false }
};
}
case ActionTypes.ADD_POSTS: {
return {
...state,
timeline: {
...state.timeline,
posts: [...state.timeline.posts, ...action.payload]
}
};
}
case ActionTypes.SELECT_POST: {
return {
...state,
timeline: {
...state.timeline,
selectedPost: action.payload,
selectedComment: null
}
};
}
case ActionTypes.SELECT_COMMENT: {
return {
...state,
timeline: {
...state.timeline,
selectedComment: action.payload
}
};
}
default:
return state;
}
};
const AppProvider = ({ initialState, reducer, children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};
You can find my random tech ramblings on tweeter @basicbrogrammer
References
Shout out to Kent Dodds. He has some killer React patterns on his blog. Check it out.
Posted on January 25, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.