Cannot pass selected date from MUI DateTimePicker into SWR hook - reactjs

I have a component that holds a MUI DataGrid.
Now, for any row of the DataGrid I need to render a DateTimePicker column. There is data coming
in with a SWR call which data could contain a datetime for the column or not.
mainPage/sdk.js
export const useSomeData = (
params: TUseFetchOptions['params']
) => {
const { data, isLoading, mutate } = useFetch<TPagination<TSomeData>>('/some-data/');
const approve = useCallback(
(someData: TSomeData) => {
const data = { published_at: someData.published_at }
return requestHandler(
post(`/some-data/update/`, data)
)
.then((someData) => {
mutate((prev) => {
if (!prev) {
return prev;
}
// some irrelevant mutation for swr caching happens here
return prev;
}
return prev;
}, false);
})
.catch((error) =>
// some irrelevant alerting happens here
);
},
[mutate]
);
return useMemo(
() => ({ someData: data, isLoading, approve,mutate }),
[data, isLoading, mutate, approve]
);
};
mainPage/index.tsx
import {useSomeData} from './sdk'
const SomeDataPublish = () => {
// const {params} = ....
// const dataGridProps = ...
const { someData, isLoading, approve } = useSomeData(params);
return (
<Stack>
{someData && (
<SomeDataDataGrid
someData={someData}
params={params}
DataGridProps={dataGridProps}
handleApprove={approve}
/>
)}
</Stack>
);
};
export default SomeDataPublish;
mainPage/componenets/someDataDataGrid.tsx
export const columns: GridColumns = [
{
// some field
},
{
// some field
},
{
// some field
},
// ...
];
const buildColumnsData = (
handleApprove: ReturnType<typeof useSomeData>['approve'],
): GridColumns => {
return [
...columns,
{
field: 'published_at',
headerName: 'Publish at',
flex: 0.75,
renderCell: (params: any) => <RowDatePicker params={params} />
},
{
field: '',
type: 'actions',
flex: 0.4,
getActions: (params: any) => [
<RowActions
params={params}
handleApprove={handleApprove}
/>
]
}
];
};
const buildRows = (someData: TSomeData[]): GridRowsProp => {
return someData.map((row) => ({
id: row.id,
// ...
published_at: _.get(row, 'published_at'),
}));
};
const SomeDataDataGrid: FC<{
someData: TPagination<TSomeData>;
params: TUseFetchOptions['params'];
DataGridProps: Partial<MuiDataGridProps>;
handleApprove: ReturnType<typeof useSomeData>['approve'];
}> = ({ someData, params, DataGridProps, handleApprove }) => {
return (
<Paper>
<DataGrid
// ...
columns={buildColumnsData(handleApprove)}
rows={someData ? buildRows(someData.results) : []}
// ...
{...DataGridProps}
/>
</Paper>
);
};
export default SomeDataDataGrid;
mainPage/componenets/rowDatePicker.tsx
const RowDatePicker: React.FC<{
params: GridRowParams;
}> = ({ params }) => {
const [publishedAt, setPublishedAt] = React.useState(params.row.published_at);
return (
<>
<DateTimeField
label={'Pick Date'}
value={publishedAt}
onChange={setPublishedAt}
/>
</>
);
};
export default RowDatePicker;
mainPage/componenets/rowAction.tsx
const RowActions: React.FC<{
params: GridRowParams;
handleApprove: ReturnType<typeof useSomeData>['approve'];
}> = ({ params, handleApprove }) => {
return (
<>
<Tooltip title="Approve">
<IconButton
color="success"
disabled={false}
onClick={(e) => {
console.log(params.row)}
handleApprove(params.row)
>
<AppIcons.CheckCircle />
</IconButton>
</Tooltip>
</>
);
};
export default RowActions;
The problem that I have - if I change the date from the date picker, on clicking the <AppIcons.CheckCircle /> in the RowActions component I expect the row.published_at to be updated with the new value. Then I pass the new updated object (with the updated published_at attribute) to the handleApprove hook so I can make some mutations and pass the updated object (with new published_at value) to the back end.
However, on examining the someData object that is passed to the approve hook the published_at field has its old value ( the one that came from the SWR fetcher).
I know that I need to mutate somehow params.row.published_at = publishedAt in the onChange callback of the RowDatePicker.DateTimePicker, but I am not sure how to do it. Any help would be appreciated.

Related

How to use RTK query selector with an argument?

I use RTK Query to consume an /items/filter API. In the codes below, the debouncedQuery holds a value of query string parameter to be used to call a filter API endpoint. e.g.: In the /items/filter?item_name=pencil and return the matched results. When it's empty, then /items/filter is called and returns a limited number of results (20 items).
So far, /items/filter returns the results and are displayed as expected while the application is started.
When I passed a filter param /items/filter?item_name={debouncedQuery}, it returned the results. But, it was not shown because, in the Item Detail Component, the selectItemById does not return any result with the provided ids.
Bellow are sample code:
Search Item Component:
export function SearchItem(props: SearchItemProps) {
const {onSelectedItem} = props;
const [itemName, setItemName] = useState<string|undefined>(undefined);
const debouncedQuery = useDebounce(itemName, 500);
const {currentData: items, refetch, isLoading, isFetching, isSuccess} = useFilterItemsQuery(debouncedQuery, {
refetchOnFocus: true,
refetchOnMountOrArgChange: true,
skip: false,
selectFromResult: ({data, error, isLoading, isFetching, isSuccess}) => ({
currentData: data,
error,
isLoading,
isFetching,
isSuccess
}),
});
const ids = items?.ids
useEffect(() => {
refetch();
}, []);
const handleOnChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.toLowerCase();
setItemName(value);
}
let content;
if (isLoading || isFetching) {
content = <div style={{display: 'flex', justifyContent: 'center', alignItems: 'center', marginTop: 50}}>
<Spinner animation="grow" variant="dark"/>
</div>;
}
if (!ids?.length) {
content = <Alert variant="dark">
<Alert.Heading>Oh snap! What happened?</Alert.Heading>
<p>The item: {itemName} is not found!</p>
</Alert>;
}
if (isSuccess) {
content = ids?.length ? <ListGroup>
{ids.map((itemId: EntityId, index: number) => {
return <ItemDetail key={index} index={index} id={itemId} onSelectedItem={onSelectedItem}/>
})}
</ListGroup> : null;
}
return (
<>
<Card className="bg-secondary bg-opacity-10 pt-3">
<Card.Header>
<SearchForm name="item_name" placeholder="Search Item" onChange={handleOnChange}/>
</Card.Header>
<Card.Body style={{minHeight: 544, maxHeight: 544, overflowY: "auto"}}>
{content}
</Card.Body>
</Card>
</>
)
}
Item Detail Component
export function ItemDetail(props: ItemProps) {
const {index, id, onSelectedItem} = props;
const item = useAppSelector(state => {
return selectItemById(state, id);
});
console.log("item: ", item);
const handleOnClickedItem = (selectedItem: Item) => {
onSelectedItem(selectedItem);
}
return <ListGroup.Item
action
onClick={() => handleOnClickedItem(item!)}
className={"d-flex justify-content-between align-items-start"}
key={item?.item_uuid}
variant={index % 2 === 0 ? "light" : "dark"}
>
<div className="ms-2 me-auto">
<div>{item?.item_name}</div>
</div>
<Badge bg="dark" className={"bg-opacity-50"} style={{minWidth: 100}}>
<NumberFormat
value={item?.price}
displayType={'text'}
thousandSeparator={true}
prefix={''}
renderText={(formattedValue: string) => <div>{formattedValue}</div>}
/>
</Badge>
</ListGroup.Item>
}
Item ApiSlice
const itemsAdapter = createEntityAdapter<Item>()
const initialState = itemsAdapter.getInitialState();
export const itemApiSlice = catalogApiSlice.injectEndpoints({
endpoints: builder => ({
filterItems: builder.query({
query: (arg) => {
const url = CATALOG_FILTER_ITEMS
if (arg) {
return {
url,
params: {item_name: arg},
};
} else {
return {url};
}
},
transformResponse(response: { data: Item[] }) {
return itemsAdapter.setAll(initialState, response.data)
},
providesTags: (result: Item[] | any) => {
if (result.ids.length) {
// #ts-ignore
return [...result.ids.map(({id}) => ({type: 'Items' as const, id})), {
type: 'Items',
id: 'FILTER_LIST'
}];
} else return [{type: 'Items', id: 'FILTER_LIST'}];
},
}),
getItems: builder.query({
query: () => CATALOG_ITEMS,
transformResponse(response: { data: Item[] }) {
return response.data;
},
providesTags: (result, error, arg) => {
// #ts-ignore
return result
? [
...result.map(({id}) => ({type: 'Items' as const, id})),
{type: 'Items', id: 'LIST'},
]
: [{type: 'Items', id: 'LIST'}]
},
}),
getItem: builder.query({
query: id => {
return {
url: `${CATALOG_ITEMS}/${id}`,
};
},
transformResponse(response: { data: Item }) {
return response.data;
},
providesTags: (result, error, arg) => {
// #ts-ignore
return result
? [
{type: 'Items' as const, id: result.id},
{type: 'Items', id: 'DETAIL'},
]
: [{type: 'Items', id: 'DETAIL'}]
},
}),
})
})
export const {
useGetItemsQuery,
useFilterItemsQuery,
useGetItemQuery
} = itemApiSlice
export const selectItemsResult = itemApiSlice.endpoints.filterItems.select();
const selectItemsData = createDraftSafeSelector(
selectItemsResult,
itemsResult => {
return itemsResult.data
}
)
export const {
selectAll: selectAllItems,
selectById: selectItemById,
selectIds: selectItemIds
} = itemsAdapter.getSelectors((state: any) => selectItemsData(state) ?? initialState);
I am wondering how I can get that debouncedQuery in select() or how to update the memoized select in each /items/filter?item_name={debouncedQuery}.
Thank you
This is a pattern you should not use - for the reason you found here.
export const selectItemsResult = itemApiSlice.endpoints.filterItems.select();
is the same as
export const selectItemsResult = itemApiSlice.endpoints.filterItems.select(undefined);
and will always give you the result of useFilterItemsQuery()/useFilterItemsQuery(undefined).
If you call useFilterItemsQuery(5), you also have to create a selector using
export const selectItemsResult = itemApiSlice.endpoints.filterItems.select(5);
.
and all other selectors would have to depend on that.
Of course, that doesn't scale.
Good thing: it's also absolutely unneccessary.
Instead of calling
const item = useAppSelector(state => {
return selectItemById(state, id);
});
in your component, call useFilterItemsQuery with a selectFromResult method and directly use the selectById selector within that selectFromResults function - assuming you did get it by just calling itemsAdapter.getSelectors() and are passing result.data into the selectById selector as state argument.

useCallback and memoization

How the memorized callback function works? In some articles I read that the function is recreated if we do not use useCallback. But if it is recreated, should it be different from the prev version? In my code I didn't notice that there was a difference in callback functions.
My question is: Why in both cases, my set size is 1?
from off doc useCallback
Returns a memoized callback.
Pass an inline callback and an array of dependencies. useCallback will
return a memoized version of the callback that only changes if one of
the dependencies has changed. This is useful when passing callbacks to
optimized child components that rely on reference equality to prevent
unnecessary renders (e.g. shouldComponentUpdate).
import { useCallback } from "react";
const dataSource = [
{
id: 1,
model: "Honda",
color: "red",
},
{
id: 2,
model: "Mazda",
color: "yellow",
},
{
id: 3,
model: "Toyota",
color: "green",
},
];
const Car = ({ model, color, set, onCarClick }) => {
const onClick = () => onCarClick(model, color);
set.add(onCarClick);
console.log(set.size);
return (
<div onClick={onClick}>
Model: {model} Color: {color}
</div>
);
};
const CarsCallback = ({ cars, set }) => {
const onCarClick = (model, color) => {
console.log(model, color);
};
console.log("CarsCallback");
return (
<>
{cars.map((car) => {
return (
<Car
key={car.id}
set={set}
{...car}
onCarClick={onCarClick}
/>
);
})}
</>
);
};
const CarsUseCallback = ({ cars, set }) => {
const onCarClick = useCallback((model, color) => {
console.log(model, color);
}, []);
console.log("CarsUseCallback");
return (
<>
{cars.map((car) => {
return (
<Car
key={car.id}
{...car}
set={set}
onCarClick={onCarClick}
/>
);
})}
</>
);
};
export default function App() {
return (
<div className="App">
<CarsCallback cars={dataSource} set={new Set()} />
<CarsUseCallback cars={dataSource} set={new Set()} />
</div>
);
}
Because CarsUseCallback and CarsCallback was triggered once.
We can see the only one log of CarsUseCallback and CarsCallback.
If we re-render the CarsUseCallback and CarsCallback, we can see the size is 1 and 2.
const CarsCallback = ({ cars, set }) => {
const [count, setCount] = useState(1);
console.log('CarsCallback');
useEffect(() => {
setCount(2);
}, []);
// ...
};
const CarsUseCallback = ({ cars, set }) => {
const [count, setCount] = useState(1);
console.log('CarsUseCallback');
useEffect(() => {
setCount(2);
}, []);
// ...
}

Infinite call renderCell in React

I'm using function component to create a MUI dataGrid, and trying to add a button in a column, and I have a onRowClick function to open a side pane when user clicking row. The problem is, once I click row, react will report error:
Error: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
Here is the code:
const openViewPane = (params: GridRowParams, e): void => {
setRightSlidePlaneContent(
<ViewAccountPane
close={closeForm}
params={params}
/>,
);
setRightSlidePlaneOpen(true);
};
const formatDates = (columns): GridColDef[] => {
return columns;
};
const addTooltipsToData = (columns: GridColDef[]): GridColDef[] => {
console.log('render tool bar called');
return columns.map((column) => {
const { description, field, headerName } = column;
console.log('inside map');
if (field === ID) {
console.log('直接return');
return column;
}
return {
...column,
renderCell: (): JSX.Element => {
console.log('render run');
return (
<Tooltip arrow title={description || ''} >
<span className={classes.headerCell}>{headerName}</span>
</Tooltip>
);
},
};
});
};
const formatColumns = (columns: GridColDef[]): GridColDef[] => {
const dateFormatted = formatDates(columns);
return addTooltipsToData(dateFormatted);
};
console.log('generic table rendered');
return (
<MuiThemeProvider theme={theme}>
<DataGrid
columns={formatColumns(columns)}
rows={rows}
autoHeight
className={classes.table}
components={{
Toolbar: CustomToolbar,
}}
density={GridDensityTypes.Compact}
filterMode={tableMode}
hideFooterSelectedRowCount
loading={loading}
onFilterModelChange={handleFilterChange}
onSortModelChange={handleSortChange}
sortModel={sortModel}
sortingMode={tableMode}
onRowClick={openViewPane}
/>
</MuiThemeProvider>
);
However, if I change the renderCell to renderHeader, it will work fine.
setRightSlidePlaneContent
setRightSlidePlaneOpen
Above are two state passed by parent component in props. it will open a slide pane.
After I comment setRightSliePlaneOpen, it will work well. But no slide pane show.
Please help me slove it. Or do you know how can I add a button in column not using renderCell?
const PageFrame: FC<IProps> = (props: IProps) => {
const classes = useStyles();
const dispatch = useAppDispatch();
const { Component, userInfo } = props;
const [navBarOpen, setNavBarOpen] = useState(false);
const [rightSlidePlaneOpen, setRightSlidePlaneOpen] = useState(false);
const [rightSlidePlaneContent, setRightSlidePlaneContent] = useState(
<Fragment></Fragment>,
);
const [rightSlidePlaneWidthLarge, setRightSlidePlaneWidthLarge] = useState(
false,
);
useEffect(() => {
dispatch({
type: `${GET_USER_LOGIN_INFO}_${REQUEST}`,
payload: {
empId: userInfo.empId,
auth: { domain: 'GENERAL_USER', actionType: 'GENERAL_USER', action: 'VIEW', empId: userInfo.empId},
},
meta: { remote: true },
});
}, []);
return (
<div className={classes.root}>
<HeaderBar
navBarOpen={navBarOpen}
toggleNavBarOpen={setNavBarOpen}
/>
<NavigationBar open={navBarOpen} toggleOpen={setNavBarOpen} />
<Component
setRightSlidePlaneContent={setRightSlidePlaneContent}
setRightSlidePlaneOpen={setRightSlidePlaneOpen}
setRightSlidePlaneWidthLarge={setRightSlidePlaneWidthLarge}
/>
<PersistentDrawerRight
content={rightSlidePlaneContent}
open={rightSlidePlaneOpen}
rspLarge={rightSlidePlaneWidthLarge}
/>
</div>
);
};
export default PageFrame;
The component that calls setRightSidePlaneOpen
interface IProps {
setRightSlidePlaneContent: React.Dispatch<React.SetStateAction<JSX.Element>>;
setRightSlidePlaneOpen: React.Dispatch<React.SetStateAction<boolean>>;
setRightSlidePlaneWidthLarge: React.Dispatch<SetStateAction<boolean>>;
}
const TagDashboard = (props: IProps): JSX.Element => {
const { setRightSlidePlaneContent, setRightSlidePlaneOpen, setRightSlidePlaneWidthLarge } = props;
const employeeId = useAppSelector((store) => store.userInfo.info.employeeNumber);
const rows = useAppSelector((state) => state.tag.rows);
const accountId = useAppSelector(store => store.userInfo.accountId);
const updateContent = useAppSelector(state => state.tag.updateContent);
const numOfUpdates = useAppSelector(state => state.tag.numOfUpdates);
const dispatch = useAppDispatch();
const closeAddForm = (): void => {
setRightSlidePlaneContent(<Fragment />);
setRightSlidePlaneOpen(false);
};
const openAddForm = (): void => {
setRightSlidePlaneContent(
<AddForm
category={'tag'}
close={closeAddForm}
title={ADD_FORM_TITLE}
createFunction={createTag}
/>);
setRightSlidePlaneOpen(true);
};
const closeForm = (): void => {
setRightSlidePlaneContent(<Fragment />);
setRightSlidePlaneOpen(false);
setRightSlidePlaneWidthLarge(false);
};
const openViewPane = (params: GridRowParams, e): void => {
setRightSlidePlaneContent(
<ViewAccountPane
close={closeForm}
params={params}
/>,
);
setRightSlidePlaneOpen(true);
setRightSlidePlaneWidthLarge(true);
};
// to the RSP.
return (
<GenericDashboard
addFunction={openAddForm}
description={DESCRIPTION}
title={TITLE}
columns={columns}
handleRowClick={openViewPane}
rows={rows}
numOfUpdates={numOfUpdates}
updateContent={updateContent}
/>
);
};
This is the component of the right slide pane
const { content, open, rspLarge } = props;
const classes = useStyles();
const drawerClass = rspLarge ? classes.drawerLarge : classes.drawer;
const drawerPaperClass = rspLarge ? classes.drawerPaperLarge : classes.drawerPaper;
return (
<div className={classes.root}>
<CssBaseline />
<Drawer
className={drawerClass}
variant='temporary'
anchor='right'
open={open}
classes={{
paper: drawerPaperClass,
}}
>
<Fragment>{content}</Fragment>
</Drawer>
</div>
);

Best way to use useMemo/React.memo for an array of objects to prevent rerender after edit one of it?

I'm struggling with s performance issue with my React application.
For example, I have a list of cards which you can add a like like facebook.
Everything, all list is rerendering once one of the child is updated so here I'm trying to make use of useMemo or React.memo.
I thought I could use React.memo for card component but didn't work out.
Not sure if I'm missing some important part..
Parent.js
const Parent = () => {
const postLike= usePostLike()
const listData = useRecoilValue(getCardList)
// listData looks like this ->
//[{ id:1, name: "Rose", avararImg: "url", image: "url", bodyText: "text", liked: false, likedNum: 1, ...etc },
// { id:2, name: "Helena", avararImg: "url", image: "url", bodyText: "text", liked: false, likedNum: 1, ...etc },
// { id: 3, name: "Gutsy", avararImg: "url", image: "url", bodyText: "text", liked: false, likedNum: 1, ...etc }]
const memoizedListData = useMemo(() => {
return listData.map(data => {
return data
})
}, [listData])
return (
<Wrapper>
{memoizedListData.map(data => {
return (
<Child
key={data.id}
data={data}
postLike={postLike}
/>
)
})}
</Wrapper>
)
}
export default Parent
usePostLike.js
export const usePressLike = () => {
const toggleIsSending = useSetRecoilState(isSendingLike)
const setList = useSetRecoilState(getCardList)
const asyncCurrentData = useRecoilCallback(
({ snapshot }) =>
async () => {
const data = await snapshot.getPromise(getCardList)
return data
}
)
const pressLike = useCallback(
async (id) => {
toggleIsSending(true)
const currentList = await asyncCurrentData()
...some api calls but ignore now
const response = await fetch(url, {
...blabla
})
if (currentList.length !== 0) {
const newList = currentList.map(list => {
if (id === list.id) {
return {
...list,
liked: true,
likedNum: list.likedNum + 1,
}
}
return list
})
setList(newList)
}
toggleIsSending(false)
}
},
[setList, sendYell]
)
return pressLike
}
Child.js
const Child = ({
postLike,
data
}) => {
const { id, name, avatarImg, image, bodyText, likedNum, liked } = data;
const onClickPostLike = useCallback(() => {
postLike(id)
})
return (
<Card>
// This is Material UI component
<CardHeader
avatar={<StyledAvatar src={avatarImg} />}
title={name}
subheader={<SomeImage />}
/>
<Image drc={image} />
<div>{bodyText}</div>
<LikeButton
onClickPostLike={onClickPostLike}
liked={liked}
likedNum={likedNum}
/>
</Card>
)
}
export default Child
LikeButton.js
const LikeButton = ({ onClickPostLike, like, likedNum }) => {
const isSending = useRecoilValue(isSendingLike)
return (
<Button
onClick={() => {
if (isSending) return;
onClickPostLike()
}}
>
{liked ? <ColoredLikeIcon /> : <UnColoredLikeIcon />}
<span> {likedNum} </span>
</Button>
)
}
export default LikeButton
The main question here is, what is the best way to use Memos when one of the lists is updated. Memorizing the whole list or each child list in the Parent component, or use React.memo in a child component...(But imagine other things could change too if a user edits them. e.g.text, image...)
Always I see the Parent component is highlighted with React dev tool.
use React.memo in a child component
You can do this and provide a custom comparator function:
const Child = React.memo(
({
postLike,
data
}) => {...},
(prevProps, nextProps) => prevProps.data.liked === nextProps.data.liked
);
Your current use of useMemo doesn't do anything. You can use useMemo as a performance optimization when your component has other state updates and you need to compute an expensive value. Say you have a collapsible panel that displays a list:
const [expand, setExpand] = useState(true);
const serverData = useData();
const transformedData = useMemo(() =>
transformData(serverData),
[serverData]);
return (...);
useMemo makes it so you don't re-transform the serverData every time the user expands/collapses the panel.
Note, this is sort of a contrived example if you are doing the fetching yourself in an effect, but it does apply for some common libraries like React Apollo.

Updating Recursive Object state in Redux

I want to update the 'name' stored in child object of redux state.
Currently, I am using redux toolkit and storing, 'TElement' data (from api) in redux state.
TElement has recursice data structure.
I was able to map out all the child components in React. However, I don't know how to go about updating the state of TElement's elements.
createSlice.ts
export interface TElement {
id: string;
name: string;
link: string;
elements: TElement[];
};
const initalState: TElements = {
TElement: {
id: '',
name: '',
link: '',
elements: []
}
}
const systemSlice = createSlice({
name: 'system',
initialState: initialState as TElements,
reducers:{}
})
export const root = (state: RootState): TElements['TElement'] =>
state.system.TElement;
Component.tsx
'Wish to update name in input field'
const File: React.FC<TElement> = ({
id,
name,
link,
elements,
}: TElement) => {
const [showChildren, setShowChildren] = useState<boolean>(false);
const handleClick = useCallback(() => {
setShowChildren(!showChildren);
}, [showChildren, setShowChildren]);
return (
<div>
<input
onClick={handleClick}
style={{ fontWeight: showChildren ? 'bold' : 'normal' }}>
{name}
</input>
<div
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
left: 25,
borderLeft: '1px solid',
paddingLeft: 15,
}}>
{showChildren &&
(child ?? []).map((node: FileNode) => <File key={id} {...node} />)}
</div>
</div>
)
function TaskFilter(): JSX.Element {
const root = useSelector(root);
return (
<div>
<File {...root} />
</div>
);
}
export default TaskFilter;
My recommendation would be to store them in a flat structure. This makes it more difficult to store them (if they are coming from the API in a nested structure), but much easier to update them.
You would store a dictionary of elements keyed by their id so that you can look up and update an element easily. You would replace the recursive element property with an array of the childIds of the direct children.
export interface TElement {
id: string;
name: string;
link: string;
elements: TElement[];
}
export type StoredElement = Omit<TElement, "elements"> & {
childIds: string[];
};
Here's what your slice might look like:
export const elementAdapter = createEntityAdapter<StoredElement>();
const flatten = (
element: TElement,
dictionary: Record<string, StoredElement> = {}
): Record<string, StoredElement> => {
const { elements, ...rest } = element;
dictionary[element.id] = { ...rest, childIds: elements.map((e) => e.id) };
elements.forEach((e) => flatten(e, dictionary));
return dictionary;
};
const systemSlice = createSlice({
name: "system",
initialState: elementAdapter.getInitialState({
rootId: "" // id of the root element
}),
reducers: {
receiveOne: (state, { payload }: PayloadAction<TElement>) => {
elementAdapter.upsertMany(state, flatten(payload));
},
receiveMany: (state, { payload }: PayloadAction<TElement[]>) => {
payload.forEach((element) =>
elementAdapter.upsertMany(state, flatten(element))
);
},
rename: (
state,
{ payload }: PayloadAction<Pick<TElement, "id" | "name">>
) => {
const { id, name } = payload;
elementAdapter.updateOne(state, { id, changes: { name } });
}
}
});
export const { receiveOne, receiveMany, rename } = systemSlice.actions;
export default systemSlice.reducer;
And the store:
const store = configureStore({
reducer: {
system: systemSlice.reducer
}
});
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;
export const useSelector = createSelectorHook<RootState>();
const { selectById } = elementAdapter.getSelectors(
(state: RootState) => state.system
);
And your components:
const RenderFile: React.FC<StoredElement> = ({ id, name, link, childIds }) => {
const dispatch = useDispatch();
const [showChildren, setShowChildren] = useState(false);
const handleClick = useCallback(() => {
setShowChildren((prev) => !prev);
}, [setShowChildren]);
const [text, setText] = useState(name);
const onSubmitName = () => {
dispatch(rename({ id, name: text }));
};
return (
<div>
<div>
<label>
Name:
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</label>
<button onClick={onSubmitName}>Submit</button>
</div>
<div>
<div onClick={handleClick}>
Click to {showChildren ? "Hide" : "Show"} Children
</div>
{showChildren && childIds.map((id) => <FileById key={id} id={id} />)}
</div>
</div>
);
};
const FileById: React.FC<{ id: string }> = ({ id }) => {
const file = useSelector((state) => selectById(state, id));
if (!file) {
return null;
}
return <RenderFile {...file} />;
};
const TaskFilter = () => {
const rootId = useSelector((state) => state.system.rootId);
return (
<div>
<FileById id={rootId} />
</div>
);
};
export default TaskFilter;
Code Sandbox Link
To understand recursion you have to understand recursion. Here is an example that will recursively render and in the action provide all the parent ids to the update so the reducer can recursively update.
const { Provider, useDispatch, useSelector } = ReactRedux;
const { createStore, applyMiddleware, compose } = Redux;
const initialState = {
elements: [
{
id: '1',
name: 'one',
elements: [
{
id: '2',
name: 'two',
elements: [
{
id: '3',
name: 'three',
elements: [],
},
],
},
],
},
{
id: '4',
name: 'four',
elements: [],
},
],
};
//action types
const NAME_CHANGED = 'NAME_CHANGED';
//action creators
const nameChanged = (parentIds, id, newName) => ({
type: NAME_CHANGED,
payload: { parentIds, id, newName },
});
//recursive update for reducer
const recursiveUpdate = (
elements,
parentIds,
id,
newName
) => {
const recur = (elements, parentIds, id, newName) => {
//if no more parent ids
if (parentIds.length === 0) {
return elements.map((element) =>
element.id === id
? { ...element, name: newName }
: element
);
}
const currentParent = parentIds[0];
//recursively update minus current parent id
return elements.map((element) =>
element.id === currentParent
? {
...element,
elements: recursiveUpdate(
element.elements,
parentIds.slice(1),
id,
newName
),
}
: element
);
};
return recur(elements, parentIds, id, newName);
};
const reducer = (state, { type, payload }) => {
if (type === NAME_CHANGED) {
const { parentIds, id, newName } = payload;
return {
...state,
elements: recursiveUpdate(
state.elements,
parentIds,
id,
newName
),
};
}
return state;
};
//selectors
const selectElements = (state) => state.elements;
//creating store with redux dev tools
const composeEnhancers =
window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
reducer,
initialState,
composeEnhancers(
applyMiddleware(() => (next) => (action) =>
next(action)
)
)
);
//Element will recursively call itself
const Element = React.memo(function ElementComponent({
parentIds,
element,
}) {
const dispatch = useDispatch();
const onNameChange = (e) =>
dispatch(
nameChanged(parentIds, element.id, e.target.value)
);
const { id } = element;
console.log('render', id);
//make parentIds array for children, use memo to not needlessly
// re render all elements on name change
const childParentIds = React.useMemo(
() => parentIds.concat(id),
[parentIds, id]
);
return (
<li>
<input
type="text"
value={element.name}
onChange={onNameChange}
/>
{/* SO does not support optional chaining but you can use
Boolean(element.elements?.length) instead */}
{Boolean(
element.elements && element.elements.length
) && (
<ul>
{element.elements.map((child) => (
// recursively render child elements
<Element
key={child.id}
element={child}
parentIds={childParentIds}
/>
))}
</ul>
)}
</li>
);
});
const App = () => {
const elements = useSelector(selectElements);
const parentIds = React.useMemo(() => [], []);
return (
<ul>
{elements.map((element) => (
<Element
key={element.id}
parentIds={parentIds}
element={element}
/>
))}
</ul>
);
};
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>
<div id="root"></div>

Resources