In the effort to better learn React, TypeScript, and Context / Hooks, I'm making a simple Todo app. However, the code needed to make the context feels cumbersome.
For example, if I want to change what a Todo has, I have to change it in three places (ITodo interface, default context value, default state value). If I want to pass down something new, I have to do that in three places (TodoContext, TodoContext's default value, and value=). Is there a better way to not have to write so much code?
import React from 'react'
export interface ITodo {
title: string,
body?: string,
id: number,
completed: boolean
}
interface TodoContext {
todos: ITodo[],
setTodos: React.Dispatch<React.SetStateAction<ITodo[]>>
}
export const TodoContext = React.createContext<TodoContext>({
todos: [{title: 'loading', body: 'loading', id: 0, completed: false}],
setTodos: () => {}
})
export const TodoContextProvider: React.FC<{}> = (props) => {
const [todos, setTodos] = React.useState<ITodo[]>([{title: 'loading', body: 'loading', id: 0, completed: false}])
return (
<TodoContext.Provider value={{todos, setTodos}}>
{props.children}
</TodoContext.Provider>
)
}
There's no way of avoiding declaring the interface and the runtime values, because TS's types disappear at runtime, so you're only left with the runtime values. You can't generate one from the other.
However if you know that you are only ever going to access the context within the TodoContextProvider component you can avoid initialising TodoContext by cheating a little bit and just telling TS that what you're passing it is fine.
const TodoContext = React.createContext<TodoContext>({} as TodoContext)
If you do always make sure to only access the context inside of TodoContextProvider where todos and setTodos are created with useState then you can safely skip initialising TodoContext inside of createContext because that initial value will never actually be accessed.
Note from the react documentation:
The defaultValue argument is only used when a component does not have a matching Provider above it in the tree.
The way I prefer to do it is by actually specifying that the default value can be undefined
const TodoContext = React.createContext<ITodoContext | undefined>(undefined)
And then, in order to use the context, I create a hook that does the check for me:
function useTodoContext() {
const context = useContext(TodoContext)
if (context === undefined) {
throw new Error("useTodoContext must be within TodoProvider")
}
return context
}
Why I like this approach?
It is immediately giving me feedback on why my context value is undefined.
For further reference, have a look at this blog post by Kent C. Dodds
After awhile, I think I've found the best way to go about this.
import React from 'react'
export interface ITodo {
title: string,
body?: string,
id: number,
completed: boolean
}
const useValue = () => {
const [todos, setTodos] = React.useState<ITodo[]>([])
return {
todos,
setTodos
}
}
export const TodoContext = React.createContext({} as ReturnType<typeof useValue>)
export const TodoContextProvider: React.FC<{}> = (props) => {
return (
<TodoContext.Provider value={useValue()}>
{props.children}
</TodoContext.Provider>
)
}
This way, there is single point of change when adding something new to your context, rather than triple point of change originally. Enjoy!
My situation might be a little different than yours (and I realize there's already an accepted answer), but this seems to work for me for now.
Modified from Aron's answer above because using that technique didn't actually work in my case.
The name of my actual context is different of course.
export const TodoContext = createContext<any>({} as any)
Related
I'm building a simple review app with react and redux toolkit.
Reviews are added via a form in AddReview.js, and I'm wanting to display these reviews in Venue.js.
When I submit a review in AddReview.js, the new review is added to state, as indicated in redux dev tools:
However when I try to pull that state from the store in Venue.js, I only get the initial state (the first two reviews), and not the state I've added via the submit form:
Can anyone suggest what's going wrong here?
Here's how I've set up my store:
store.js
import { configureStore } from "#reduxjs/toolkit";
import reviewReducer from '../features/venues/venueSlice'
export const store = configureStore({
reducer:{
reviews: reviewReducer
}
})
Here's the slice managing venues/reviews:
venueSlice.js
import { createSlice } from "#reduxjs/toolkit";
const initialState = [
{id:1, title: 'title 1',blurb: 'blurb 1'},
{id:2, title: 'title 2',blurb: 'blurb 2'}
]
const venueSlice = createSlice({
name: 'reviews',
initialState,
reducers: {
ADD_REVIEW: (state,action) => {
state.push(action.payload)
}
}
})
export const { ADD_REVIEW } = venueSlice.actions
export default venueSlice.reducer
And here's the Venue.js component where I want to render reviews:
import { useParams } from "react-router-dom";
import { useSelector } from "react-redux";
const Venue = () => {
const { id } = useParams()
const reviews = useSelector((state) => state.reviews)
console.log(reviews)
return (
<div>
{reviews.map(item => (
<h1>{item.title}</h1>
))}
</div>
)
}
export default Venue;
Form component AddReview.js
import { useState } from "react"
import { useDispatch } from "react-redux"
import { ADD_REVIEW } from "./venueSlice"
import { nanoid } from "#reduxjs/toolkit"
const AddReview = () => {
const [ {title,blurb}, setFormDetails ] = useState({title:'', blurb: ''})
const dispatch = useDispatch()
const handleChange = (e) => {
const { name, value } = e.target
setFormDetails(prevState => ({
...prevState,
[name]: value
}))
}
const handleSubmit = (e) => {
console.log('it got here')
e.preventDefault()
if(title && blurb){
dispatch(ADD_REVIEW({
id: nanoid(),
title,
blurb
}))
// setFormDetails({title: '', blurb: ''})
}
}
return(
<div>
<form onSubmit={handleSubmit}>
<input
type = 'text'
name = 'title'
onChange={handleChange}
/>
<input
type = 'text'
name = 'blurb'
onChange={handleChange}
/>
<button type = "submit">Submit</button>
</form>
</div>
)
}
export default AddReview;
I can notice that you pushing directly to the state, I can suggest to use variable in the state and then modify that variable.
Also I suggest to use concat instead of push. Where push will return the array length, concat will return the new array.
When your code in the reducer will looks like that:
import { createSlice } from "#reduxjs/toolkit";
const initialState = [
reviews: [{id:1, title: 'title 1',blurb: 'blurb 1'},
{id:2, title: 'title 2',blurb: 'blurb 2'}]
]
const venueSlice = createSlice({
name: 'reviews',
initialState,
reducers: {
ADD_REVIEW: (state,action) => {
state.reviews = state.reviews.concat(action.payload);
}
}
})
export const { ADD_REVIEW } = venueSlice.actions
export default venueSlice.reducer
And then your selector will looks like that:
const reviews = useSelector((state) => state.reviews.reviews)
Your code seems to be fine. I don't see any reason why it shouldn't work.
I run your code on stackblitz react template and its working as expected.
Following is the link to the app:
stackblitz react-redux app
Link to the code:
Project files react-redux
if you are still unable to solve the problem, do create the sandbox version of your app with the issue to help further investigate.
Thanks
Expanding on #electroid answer (the solution he provided should fix your issue and here is why):
Redux toolkit docs mention on Rules of Reducers :
They are not allowed to modify the existing state. Instead, they must make immutable updates, by copying the existing state and making changes to the copied values.
and on Reducers and Immutable Updates :
One of the primary rules of Redux is that our reducers are never allowed to mutate the original / current state values!
And as mdn docs specify the push method changes the current array (so it mutates your state). You can read more about mutating the state in the second link link (Reducers and Immutable Updates).
If you really want to keep the state.reviews and avoid state.reviews.reviews you could also do something like this:
ADD_REVIEW: (state,action) => {
state = [...state, action.payload];
}
But I wouldn't recommend something like this in a real app (it is avoided in all the examples you can find online). Some reason for this would be:
It harder to work with, read and track the state when having an overall dynamic state instead of a state structure
It leads to a lot of slices in a real app (creating a slices for an array without grouping the data) which can also become hard to track and maintain.
Usually you need a redux slice in multiple parts of the app (otherwise you can just use state). That data is usually bigger than just an array and not grouping the data properly on reducers can become very very confusing.
But I would definitely advise to use something else (not reviews.reviews). In your case I think something like state.venue.reviews
(so on store.js
...
export const store = configureStore({
reducer:{
venue: reviewReducer // reviewReducer should probably also be renamed to venueSlice or venueReducer
}
})
So an option to avoid state.venue.reviews or state.reviews.reviews would be to export a selector from the venueSlice.js:
export const selectReviews = (state) => state.venue.reviews
and in your Venue.js component you can just use
const reviews = useSelector(selectReviews)
Exporting a selector is actually suggested by the redux toolkit tutorials as well (this link is for typescript but the same applies to javascript). Although this is optional.
I'm having some trouble modifying the default value given by a context. Below is a heavily simplified code, which still leads to the issue: As seen in the provider, I want greetWorld to become true, thus exhibiting "Hello world" instead of "..."
index.tsx:
import useSample, { SampleProvider } from './useSample'
const Example = () => {
const { greetWorld } = useSample()
return (
<SampleProvider>
{greetWorld ? 'Hello world' : '...'}
</SampleProvider>
)
}
useSample.tsx
import React from 'react'
type SampleContextType = {
greetWorld: boolean
}
const SampleContext = React.createContext<SampleContextType>({
greetWorld: false
})
export const SampleProvider = ({ children }: { children: React.ReactNode }) => {
const [greetWorld, setGreetWorld] = React.useState(true)
const value = React.useMemo(() => ({
greetWorld,
setGreetWorld
}), [greetWorld, setGreetWorld])
return <SampleContext.Provider value={value}>{ children }</SampleContext.Provider>
}
const useSample = () => {
const { greetWorld } = React.useContext(SampleContext)
return { greetWorld }
}
export default useSample
From my current understanding, greetWorld in index.tsx would get its value based on useSample's greetWorld, which in turn would be the greetWorld value given in SampleProvider, which is true. I've tried logging the greetWorld inside SampleProvider, and it shows true, so I'm assuming that SampleProvider is being reached properly, but I have no idea why things aren't being updated.
Regarding similar issues, this seemed rather similar, but in my simplified code there's no tag order to respect in the first place, so it can't be that, and this also seemed a little like my problem, but from what I can see, it seems like the consumer is the child already.
I get the feeling that the solution is rather obvious, as I'm unfamiliar with context hooks, but I wasn't able to find it. On a side note, since I'm also unfamiliar with memoization, I left it there, as it could be among the causes of the problem, but I also tried removing it and the problem persisted.
I'm working on my first React project and I have the following problem.
How I want my code to work:
I add Items into an array accessible by context (context.items)
I want to run a useEffect function in a component, where the context.items are displayed, whenever the value changes
What I tried:
Listing the context (both context and context.items) as a dependency in the useEffect
this resulted in the component not updating when the values changed
Listing the context.items.length
this resulted in the component updating when the length of the array changed however, not when the values of individual items changed.
wraping the context in Object.values(context)
result was exactly what I wanted, except React is now Complaining that *The final argument passed to useEffect changed size between renders. The order and size of this array must remain constant. *
Do you know any way to fix this React warning or a different way of running useEffect on context value changing?
Well, didn't want to add code hoping it would be some simple error on my side, but even with some answers I still wasn't able to fix this, so here it is, reduced in hope of simplifying.
Context component:
const NewOrder = createContext({
orderItems: [{
itemId: "",
name: "",
amount: 0,
more:[""]
}],
addOrderItem: (newOItem: OrderItem) => {},
removeOrderItem: (oItemId: string) => {},
removeAllOrderItems: () => {},
});
export const NewOrderProvider: React.FC = (props) => {
// state
const [orderList, setOrderList] = useState<OrderItem[]>([]);
const context = {
orderItems: orderList,
addOrderItem: addOItemHandler,
removeOrderItem: removeOItemHandler,
removeAllOrderItems: removeAllOItemsHandler,
};
// handlers
function addOItemHandler(newOItem: OrderItem) {
setOrderList((prevOrderList: OrderItem[]) => {
prevOrderList.unshift(newOItem);
return prevOrderList;
});
}
function removeOItemHandler(oItemId: string) {
setOrderList((prevOrderList: OrderItem[]) => {
const itemToDeleteIndex = prevOrderList.findIndex((item: OrderItem) => item.itemId === oItemId);
console.log(itemToDeleteIndex);
prevOrderList.splice(itemToDeleteIndex, 1);
return prevOrderList;
});
}
function removeAllOItemsHandler() {
setOrderList([]);
}
return <NewOrder.Provider value={context}>{props.children}</NewOrder.Provider>;
};
export default NewOrder;
the component (a modal actually) displaying the data:
const OrderMenu: React.FC<{ isOpen: boolean; hideModal: Function }> = (
props
) => {
const NewOrderContext = useContext(NewOrder);
useEffect(() => {
if (NewOrderContext.orderItems.length > 0) {
const oItems: JSX.Element[] = [];
NewOrderContext.orderItems.forEach((item) => {
const fullItem = {
itemId:item.itemId,
name: item.name,
amount: item.amount,
more: item.more,
};
oItems.push(
<OItem item={fullItem} editItem={() => editItem(item.itemId)} key={item.itemId} />
);
});
setContent(<div>{oItems}</div>);
} else {
exit();
}
}, [NewOrderContext.orderItems.length, props.isOpen]);
some comments to the code:
it's actually done in Type Script, that involves some extra syntax
-content (and set Content)is a state which is then part of return value so some parts can be set dynamically
-exit is a function closing the modal, also why props.is Open is included
with this .length extension the modal displays changes when i remove an item from the list, however, not when I modify it not changeing the length of the orderItems,but only values of one of the objects inside of it.
as i mentioned before, i found some answers where they say i should set the dependency like this: ...Object.values(<contextVariable>) which technically works, but results in react complaining that *The final argument passed to useEffect changed size between renders. The order and size of this array must remain constant. *
the values displayed change to correct values when i close and reopen the modal, changing props.isOpen indicating that the problem lies in the context dependency
You can start by creating your app context as below, I will be using an example of a shopping cart
import * as React from "react"
const AppContext = React.createContext({
cart:[]
});
const AppContextProvider = (props) => {
const [cart,setCart] = React.useState([])
const addCartItem = (newItem)=>{
let updatedCart = [...cart];
updatedCart.push(newItem)
setCart(updatedCart)
}
return <AppContext.Provider value={{
cart
}}>{props.children}</AppContext.Provider>;
};
const useAppContext = () => React.useContext(AppContext);
export { AppContextProvider, useAppContext };
Then you consume the app context anywhere in the app as below, whenever the length of the cart changes you be notified in the shopping cart
import * as React from "react";
import { useAppContext } from "../../context/app,context";
const ShoppingCart: React.FC = () => {
const appContext = useAppContext();
React.useEffect(() => {
console.log(appContext.cart.length);
}, [appContext.cart]);
return <div>{appContext.cart.length}</div>;
};
export default ShoppingCart;
You can try passing the context variable to useEffect dependency array and inside useEffect body perform a check to see if the value is not null for example.
I'm starting with React and TypeScript at the same time and I across a problem while implementing some basic authentication in my application. I've been using Ryan Chenkie's Orbit App and his course on React security as an example to start from.
Right now I'm stuck with compiler complaining about TS2722 error (Cannot invoke object which is possibly undefined) in SignIn.tsx. My suspicion is that all I have to do is to set proper types on all the data structures and function calls, but how and where to set them, somewhat alludes me. So, here's the code:
App.tsx: Nothing fancy here, just an App wrapped in context provider.
import { AuthContext, authData } from "./AuthContext"
const defAuth:authData = {
userId: 0,
name: '',
token: '',
expiresAt: ''
}
const App = () => {
return (
<AuthContext.Provider value={{ authData:defAuth }}>
<Main />
</AuthContext.Provider>
);
}
AuthContext.tsx: When calling createContext() I tried with various default parameters, the general idea is that I could call authContext.setState() and pass the data to it. I am using Partial prefix so that I don't have to pass the setState() to the Provider element.
export interface authData {
userId: number
name: string
token: string
expiresAt: string
}
interface IAuthContext {
authData: authData,
setState: (authInfo:authData) => void
}
const AuthContext = createContext<Partial<IAuthContext>>(undefined!)
const { Provider } = AuthContext
const AuthProvider: React.FC<{}> = (props) => {
const [authState, setAuthState] = useState<authData>()
const setAuthInfo = (data:authData) => {
console.log('Called setAuthInfo')
setAuthState({
userId: data!.userId,
name: data!.name,
token: data!.token,
expiresAt: data!.expiresAt
})
}
return (
<Provider
value={{
authData: authState,
setState: (authInfo:authData) => setAuthInfo(authInfo)
}} {...props}
/>
)
}
export { AuthContext, AuthProvider }
SignIn.tsx: This is again, just a basic sign in component with a form and an onSubmit handler. This is all working as it should until I add the authContext to it. I included only relevant code.
interface loginType extends Record<string, any>{
email: string,
password: string,
remember: boolean
}
const SignIn = () => {
const authContext = useContext(AuthContext)
const { register, handleSubmit, errors } = useForm<loginType>()
const onSubmit = async (data:loginType) => {
const ret = await apiFetch.post('process_login/', formData )
console.log(ret.data)
console.log('Printing context')
authContext.setState(ret.data)
console.log(authContext)
}
/* ... ... */
}
As mentioned before, compiler complains in SignIn.tsx at authContext.setState(ret.data) telling me that it might be undefined. I tried calling createContext() with various parameters, trying to pass it some defaults which would tell the compiler where that setState() will be defined later on in the runtime. I tried calling setState in a few different ways, but nothing really worked.
This is something that clearly works in plain JSX and I'd really like to find a way to make it work in TSX.
Here's what you need to do:
First in App.tsx, you have to use AuthProvider instead of AuthContext.Provider. This way you get rid of the value property.
<AuthProvider>
<Main />
</AuthProvider>
Then, in AuthContext.tsx there's no need to use Partial prefix when creating context. So, a little tweak to the IAuthContext and then pass some default data when creating context.
interface IAuthContext {
authData: authData | undefined,
setState: (authInfo:authData) => void
}
const AuthContext = createContext<IAuthContext>( {
authData: defaultAuthData,
setState: () => {}
})
Now you can call authContext.setState(), passing data as authData type.
I'm getting an error that says Error: [mobx-state-tree] A node cannot exists twice in the state tree. Failed to add SearchModel#/results/0 to path '/selectedItem' when assigning a value to selectedItem in the following model via the setSelectedItem action. I've checked the documentation and I'm not sure what's causing this issue.
Appreciate any help. thanks!
const SearchModel = types
.model({
results: types.array(ItemModel, []),
selectedItem:types.maybeNull(ItemModel,{ id: 0 })
})
.actions(self => ({
setSelectedItem(selItem) {
console.log( 'typeof(selItem)', typeof(selItem));
self.selectedItem=selItem;
}
}));
export default SearchModel;
For anyone looking for a solution to this type of an error in future, I've used the spread operator to assign a shallow copy of selItem to self.selectedItem and the problem went away.
The code had to look as follows:
const SearchModel = types
.model({
results: types.array(ItemModel, []),
selectedItem:types.maybeNull(ItemModel,{ id: 0 })
})
.actions(self => ({
setSelectedItem(selItem) {
self.selectedItem = { ...selItem };
}
}));
export default SearchModel;
Another solution is to use _.deepCopy, from the Lodash library. It's more versatile than the spread operator, since it will recursively go down an entire tree, instead of one level. That's useful for larger trees, so you don't have to double or triple or quadruple spread, and have hard-to-read code.
This is how you'd use it with a simple mobx-state-tree store. It's very elegant, easy to use.
Be advised: this is a recursive pass-by-copy function, so performance may be poor if an object is too large.
import _ from 'lodash';
import { types, getRoot, destroy, flow } from "mobx-state-tree";
const SearchModel = types
.model({
results: types.array(ItemModel, []),
selectedItem:types.maybeNull(ItemModel,{ id: 0 })
})
.actions(self => ({
setSelectedItem(selItem) {
self.selectedItem = _.deepCopy(selItem);
}
}));
export default SearchModel;
I was also facing the same issue in our React TypeScript also our MST store was very nested and complex so using the spread operator or applySnapshot didn't work. By using lodash simply solved the issue.
STEPS:
install: npm i lodash.clonedeep
import: import cloneDeep from 'lodash/cloneDeep'
Use it(In my case): self.filteredCashGamesList.cashGameTableView = cloneDeep(self.cashGameTableView)
One better possibility: use the mobx-state-tree applySnapshot function. This should auto-reconcile the data structures, and also preserves back/forth abilities, so you can take advantage of undo/redo for your state tree.
import { types, getRoot, destroy, flow, applySnapshot } from "mobx-state-tree";
const SearchModel = types
.model({
results: types.array(ItemModel, []),
selectedItem:types.maybeNull(ItemModel,{ id: 0 })
})
.actions(self => ({
setSelectedItem(selItem) {
applySnapshot(self.selectedItem, selItem);
}
}));
export default SearchModel;
In my case, I had a nested object "address" on level 2, so I needed to specify the fix for it as well:
setSelectedItem(selItem) {
let item = { ...selItem };
item.address = { ...selItem.address }
self.selectedItem = item;
}