I've architected my modals as suggested by Dan Abramov in this link. In short, he recommends rendering modals at the top level, and passing in the props at the same point that we dispatch an action to show the modal.
This works for the most part, but has one flaw that I cannot seem to overcome: The modal receives props only on the initial dispatch. If those props change at some point while the modal is open, those changes will not be reflected in the modal.
Here is my code:
MainScreen.js
const MainScreen = () => {
const { list, addListItem } = useListPicker()
const handleOpenModal = () => {
dispatch(modalShow('settingsModal', {
list,
addListItem,
}))
}
return (
/* ...some jsx */
<Button onPress={handleOpenModal} />
)
}
SettingsModal.js
const SettingsModal = ({ list, addListItem }) => {
return (
<List data={list} clickListItem={addListItem} />
)
}
useListPicker.js (where list is stored)
import produce from 'immer'
const initialState = {
list: [],
}
const reducer = (state, { type, ...action }) => {
switch (type) {
case 'ADD_LIST_ITEM':
return produce(state, (draft) => {
const { data } = action
draft.list.push(data)
})
default:
throw new Error('Called reducer without supported type.')
}
}
export default () => {
const [state, dispatch] = useReducer(reducer, initialState)
const addListItem = (data) => dispatch({ type: 'ADD_LIST_ITEM', data })
return {
list: state.list,
addListItem,
}
}
As we can see in SettingsModal, we can call the function passed down (addListItem) that will properly add to the list when called (this data lives in a custom hook). However, the SettingsModal has no way to get access to that updated list, since props are passed in only once: when we initially dispatch modalShow.
What is the best way to handle this issue? Would Dan's solution not be appropriate here? Before I refactored my modals to work in a similar way to his post, I rendered each modal in the relevant part of the tree. I refactored this because the same modal would appear elsewhere in the codebase.
Related
I am trying to use useEffect to rerender postList (to make it render without the deleted post) when postsCount change, but I can't get it right. I tried to wrap everything inside useEffect but I couldn't execute addEventListener("click", handlePost) because I am using useEffect to wait for this component to mount first, before attaching the evenListener.
Parent component:
function Tabs() {
const [posts, setPosts] = useState([]);
const dispatch = useDispatch();
const postsCount = useSelector((state) => state.posts.count);
useEffect(() => {
document.getElementById("postsTab").addEventListener("click", handlePost);
}, [handlePost]);
const handlePost = async (e) => {
const { data: { getPosts: postData }} = await refetchPosts();
setPosts(postData);
dispatch(postActions.getPostsReducer(postData));
};
const { data: FetchedPostsData, refetch: refetchPosts } = useQuery( FETCH_POSTS_QUERY, { manual: true });
const [postList, setPostsList] = useState({});
useEffect(() => {
setPostsList(
<Tab.Pane>
<Grid>
<Grid.Column>Title</Grid.Column>
{posts.map((post) => (
<AdminPostsList key={post.id} postId={post.id} />
))}
</Grid>
</Tab.Pane>
);
console.log("changed"); //it prints "changed" everytime postCount changes (or everytime I click delete), but the component doesn't remount
}, [postsCount]);
const panes = [
{ menuItem: { name: "Posts", id: "postsTab", key: "posts" }, render: () => postList }
];
return (<Tab panes={panes} />);
}
child/AdminPostsList component:
function AdminPostsList(props) {
const { postId } = props;
const [deletePost] = useMutation(DELETE_POST_MUTATION, {variables: { postId } });
const dispatch = useDispatch();
const deletePostHandler = async () => {
dispatch(postActions.deletePost(postId));
await deletePost();
};
return (
<>
<Button icon="delete" onClick={deletePostHandler}></Button>
</>
);
}
The Reducers
const PostSlice = createSlice({
name: "storePosts",
initialState: {
content: [],
count: 0,
},
reducers: {
getPostsReducer: (state, action) => {
state.content = action.payload;
state.count = action.payload.length
},
deletePost: (state, action) => {
const id = action.payload
state.content = current(state).content.filter((post) => (post.id !== id))
state.count--
}
},
});
Okay, let discuss this in separate comment. Key point is to decouple posts logic from wrapper component(Tabs). You should create component dedicated only to posts and render it in wrapper. Like that you can easily isolate all posts-related logic in posts-related component, for example to avoid attaching some listeners from wrapper(because it is not intuitive what you are doing and who listens for what because button is not in that same component). In separated component you will have only one useEffect, to fetch posts, and you will have one selector(to select posts from redux), and then just use that selection to output content from component.
That part <Tab panes={...} /> was the source of most of your problems, because like that you are forced to solve everything above <Tab../> and then just to pass it, which is not best practice in you case since it can be too complicated(especially in case when you could have multiple tabs). That is why you need to decouple and to create tab-specific components.
This would be an idea of how you should refactor it:
function PostsTab() {
const posts = useSelector((state) => state.posts?.content ?? []);
useEffect(() => {
// Here dispatch action to load your posts
// With this approach, when you have separated component for PostsTab no need to attach some weird event listeners, you can do everything here in effect
// This should be triggered only once
// You can maybe introduce 'loading' flag in your reducer so you can display some loaders for better UX
}, []);
return (
<div>
{/* Here use Tab components in order to create desired tab */}
<Tab.Pane>
<Grid>
<Grid.Column>Title</Grid.Column>
{posts.map((post) => (
<AdminPostsList key={post.id} postId={post.id} />
))}
</Grid>
</Tab.Pane>
</div>
);
}
function Tabs() {
return (
<div>
<PostsTab/>
{/** HERE you can add more tabs when you need to
* Point is to create separate component per tab so you can isolate and maintain tab state in dedicated component
and to avoid writing all logic here in wrapper component
* As you can see there is no need to attach any weird listener, everything related to posts is moved to PostsTab component
*/}
</div>
);
}
Ok, let's discuss what I did wrong for the future reader:
There is no need to use this weird spaghetti
useEffect(() => {
document.getElementById("postsTab").addEventListener("click", handlePost);
}, [handlePost]);
const panes = [
{ menuItem: { name: "Posts", id: "postsTab", key: "posts" }, render: () => postList }
];
for I could've used a <Menu.Item onClick={handleClick}>Posts</Menu.Item> to attach the onClick directly.
I had to use useEffect to monitor posts dependency, but .map() will automatically update its content if the array I am mapping had any changes so there is no need to use it use useEffect in this context.
I think I can use lifting state to setPosts from the child component and the change will trigger .map() to remap and pop the deleted element, but I couldn't find a way to so, so I am using a combination of redux (to store the posts) and useEffect to dispatch the posts to the store than I am mapping over the stored redux element, idk if this is the best approach but this is all I managed to do.
The most important thing I didn't notice when I almost tried everything is, I must update apollo-cache when adding/deleting a post, by using proxy.readQuery
this is how I did it
const [posts, setPosts] = useState([]);
const handlePosts = async () => {
const { data: { getPosts: postData } } = await refetchPosts();
setPosts(postData);
};
const handlePosts = async () => {
const { data } = await refetchPosts();
setPosts(data.getPosts);
};
// Using useEffect temporarily to make it work.
// Will replace it with an lifting state when refactoring later.
useEffect(() => {
posts && dispatch(postsActions.PostsReducer(posts))
}, [posts]);
const [deletePost] = useMutation(DELETE_POST_MUTATION, {
update(proxy) {
let data = proxy.readQuery({
query: FETCH_POSTS_QUERY,
});
// Reconstructing data, filtering the deleted post
data = { getPosts: data.getPosts.filter((post) => post.id !== postId) };
// Rewriting apollo-cache
proxy.writeQuery({ query: FETCH_POSTS_QUERY, data });
},
onError(err) {
console.log(err);
},
variables: { postId },
});
const deletePostHandler = async () => {
deletePost();
dispatch(postsActions.deletePost(postId))
};
Thanks to #Anuj Panwar #Milos Pavlovic for helping out, kudos to #Cptkrush for bringing the store idea into my attention
Suppose I have a list of items I would like to render and select (like a Todo app).
I'd like to keep the selection logic inside custom react hook and have items live somewhere else in local state.
Now, I would like to update the selection list, kept in the custom hook, whenever I fetch some more items. For this task I am passing data as parameter to selection hook and I am using useEffect to update the selection:
import { useEffect, useState } from "react";
const itemsArrayToObject = (items) =>
Object.fromEntries(items.map((i) => [i.id, { ...i, selected: false }]));
export function useSelection({ data }) {
const [selection, setSelection] = useState(itemsArrayToObject(data));
useEffect(() => {
setSelection((selection) => {
return {
...itemsArrayToObject(data),
...selection
};
});
}, [data]);
const isSelected = (itemId) => selection?.[itemId]?.selected ?? false;
const toggle = (itemId) => {
setSelection((s) => {
const item = s[itemId];
return {
...s,
[itemId]: {
...item,
selected: !item.selected
}
};
});
};
return {
isSelected,
toggle
};
}
This almost works but the problem is if I want to synchronize two things: fetching data and toggling items. Eg.
const onLoadAndToggle = async () => {
await load();
toggle(0);
};
load is a async function that fetches the data. It also triggers state update so that data is updated and the selection can be updated inside useSelection hook.
Example how it all can work:
const [data, setData] = useState([]);
const addItems = (items) => {
setData((state) => [...state, ...items]);
};
const { load } = useFetch({ addItems });
const { isSelected, toggle } = useSelection({ data });
const onLoadAndToggle = async () => {
await load();
toggle(0);
};
Now, the problem is that when calling toggle(0) my custom hook has a stale selection, even when using setState(state => ... singature.
It is because the whole fetching and updating data in state takes too long.
I can see some ugly ways to solve that problem but I wonder what would be the elegant or idiomatic react way to solve that.
I have made a code sandbox, if it helps: https://codesandbox.io/s/selection-fetch-forked-nyl0kt?file=/src/App.js:376-512
Try clicking "Load and toggle first" first to see how the app crashed because the selection is not yet updated.
What you need is to initialize toogled items from the code itself. We can do this by providing the id's of the items that we want to toggle to the hook itself.
Updated hook -
const itemsArrayToObject = (items, itemsToggled) => {
if (Array.isArray(itemsToggled)) {
return Object.fromEntries(
items.map((i) => [i.id, { ...i, selected: itemsToggled.includes(i.id) }])
);
}
return Object.fromEntries(
items.map((i) => [i.id, { ...i, selected: false }])
);
};
export function useSelection({ data }, itemsToggled) {
const [selection, setSelection] = useState(
itemsArrayToObject(data, itemsToggled)
);
useEffect(() => {
setSelection((selection) => {
return {
...itemsArrayToObject(data, itemsToggled),
...selection
};
});
}, [data, itemsToggled]);
Now call to hook becomes -
const { isSelected, toggle } = useSelection({ data }, [0, 1]);
Updated codesandbox
This also decouples loading data & toggling of an item initially.
My project use dvajs(Based on redux and redux-saga), The code below is to send a request after clicking the button, change the status through connect, and then call the ant design component message.error an message.success(Similar to alert) to remind
import type { Dispatch } from 'umi';
import ProForm, { ProFormText } from '#ant-design/pro-form';
import { message } from 'antd';
const tip = (type: string, content: string) => {
if (type === 'error') message.error(content, 5);
else message.success(content, 5);
};
const RegisterFC: React.FC<RegisterProps> = (props) => {
const { registerResponseInfo = {}, submitting, dispatch } = props;
const { status } = registerResponseInfo;
const handleSubmit = (values: RegisterParamsType) => {
dispatch({
type: 'register/register',
payload: { ...values },
});
};
return (
<div>
<ProForm
onFinish={(values) => {
handleSubmit(values as RegisterParamsType);
return Promise.resolve();
}}
>
<ProFormText/>
...
{
status === '1' && !submitting && (
tip('error',
intl.formatMessage({
id: 'pages.register.status1.message',
defaultMessage: 'error'
})
)
)
}
<<ProForm>/>
</div>
)
}
const p = ({ register, loading }: { register: RegisterResponseInfo, loading: Loading; }) => {
console.log(loading);
return {
registerResponseInfo: register,
submitting: loading.effects['register/register'],
};
};
export default connect(p)(RegisterFC);
When I click the button, the console prompts:
Warning: Render methods should be a pure function of props and state;
triggering nested component updates from render is not allowed. If
necessary, trigger nested updates in componentDidUpdate.
Doesn't the component re-render when the state changes? Does the tip function change the state?
Solution: Call tip Outside of return
tip is just a function that you are calling. You should call it outside of the return JSX section of your code. I think it makes the most sense to call it inside of a useEffect hook with dependencies on status and submitting. The effect runs each time that status or submitting changes. If status is 1 and submitting is falsy, then we call tip.
const RegisterFC: React.FC<RegisterProps> = (props) => {
const { registerResponseInfo = {}, submitting, dispatch } = props;
const { status } = registerResponseInfo;
const handleSubmit = (values: RegisterParamsType) => {
dispatch({
type: 'register/register',
payload: { ...values },
});
};
React.useEffect(() => {
if (status === '1' && !submitting) {
tip('error',
intl.formatMessage({
id: 'pages.register.status1.message',
defaultMessage: 'error'
})
);
}
}, [status, submitting]);
return (
<div>...</div>
)
}
Explanation
Render methods should be a pure function of props and state
The render section of a component (render() in class component or return in a function component) is where you create the JSX (React HTML) markup for your component based on the current values of props and state. It should not have any side effects. It creates and returns JSX and that's it.
Calling tip is a side effect since it modifies the global antd messsage object. That means it shouldn't be in the render section of the code. Side effects are generally handled inside of useEffect hooks.
You are trying to conditionally render tip like you would conditionally render a component. The problem is that tip is not a component. A function component is a function which returns a JSX Element. tip is a void function that returns nothing, so you cannot render it.
I am trying to pass the state after onClick to the component but it does not display because it is undefined. I want to display the state as the remaining number of cookies for sale
class CookieContainer extends Component {
render(){
return (
<div className="donutShop" >
//below I want to pass the updated state but {this.props.numOfCookies} is undefined
<h2>Cookies available for Sale: {this.props.numOfCookies}</h2>
<button onClick={this.props.buyCookie}>Buy cookie</button>
</div>
);
};
}
numOfCookies: is undefined when passed as props to CookieContainer
const mapStateToProps = state => ({numOfCookies: state.numOfCookies})
// dispatch
const mapDispatchToProps = dispatch => {
return {
buyCookie: () => dispatch(buyCookie())
};
};
let ConnectedCookie = connect(mapStateToProps, mapDispatchToProps)(CookieContainer)
export default ConnectedCookie
// action
export const buyCookie = () => {
return {
type: BUY_COOKIE,
content: 1
};
};
// state
const initialCookieState = {
numOfCookies: 100
}
const BUY_COOKIE = "BUY_COOKIE";
//reducer
const cookieReducer = (state = initialCookieState, action) => {
switch (action.type) {
case BUY_COOKIE:
return {
...state,
numOfCookies: state.numOfCookies - action.content
};
default:
return state;
}
};
I have created a stackblitz for the code that you provided here
and it is working for me.
The code itself seems fine. The problem might be in what you have not shown in your question. Make sure that you have created your store
const store = createStore(cookieReducer);
and passed your store properly by wrapping your app with provider
<Provider store={store}>
<App />
</Provider>
Also when declaring action types(BUY_COOKIE, etc.), declare the action types in a separate file and you can import them in the reducer. You can declare action creators and action types in the same file if you want.
I have a custom hook that keeps a list of toggle states and while I'm seeing the internal state aligning with my expectations, I'm wondering why a component that listens to changes on the state kept by this hook isn't re-rendering on change. The code is as follows
const useToggle = () => {
const reducer = (state, action) => ({...state, ...action});
const [toggled, dispatch] = useReducer(reducer, {});
const setToggle = i => {
let newVal;
if (toggled[i] == null) {
newVal = true;
} else {
newVal = !toggled[i];
}
dispatch({...toggled, [i]: newVal});
console.log('updated toggled state ...', toggled);
};
return {toggled, setToggle};
};
const Boxes = () => {
const {setToggle} = useToggle()
return Array.from({length: 8}, el => null).map((el,i) =>
<input type="checkbox" onClick={() => setToggle(i)}/>)
}
function App() {
const {toggled} = useToggle()
const memoized = useMemo(() => toggled, [toggled])
useEffect(() => {
console.log('toggled state is >>>', toggled) // am not seeing this on console after changes to toggled
}, [toggled])
return (
<div className="App">
<Boxes />
</div>
);
}
It's because you are using useToggle twice.
once in the App
another one in the Boxes.
When you dispatch the action in Boxes, it's updating the toggled instance for Boxes (which is not retrieved in it).
Think of your custom hook like how you use useState. When you use useState, each component gets its own state. Same goes for the custom hook.
So there are a few ways you can address the issue.
Pass the setToggle from App to Boxes via prop-drilling
Use Context API (or Redux or other statement management library to pass
setToggle instance in the App component down)
Here is an example of prop-drilling.
You can follow along
const Boxes = ({ setToggle }) => {
// const { setToggle } = useToggle();
return Array.from({ length: 8 }, el => null).map((el, i) => (
<input key={i} type="checkbox" onClick={() => setToggle(i)} />
));
};
function App() {
const { toggled, setToggle } = useToggle();
useEffect(() => {
console.log("toggled state is >>>", toggled); // am not seeing this on console after changes to toggled
}, [toggled]);
return (
<div className="App">
<Boxes setToggle={setToggle} />
</div>
);
}
Note: that I added key props in Boxes using the index i(and it is a bad practice by the way)
You can see that it's now working as you'd expect.