Updating Recursive Object state in Redux - reactjs

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>

Related

Cannot pass selected date from MUI DateTimePicker into SWR hook

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.

how to send function in typescript interface?

// ExpenseForm.tsx
interface ExpenseData {
enteredTitle: string;
enteredAmount: number;
enteredDate: string;
}
const ExpenseForm = (props) => {
const [userInput, setUserInput] = useState<ExpenseData>({
enteredTitle: "",
enteredAmount: 10,
enteredDate: "",
});
const { register, handleSubmit, resetField } = useForm<ExpenseData>();
const onValid = (data: ExpenseData) => {
setUserInput(data);
resetField("enteredAmount");
resetField("enteredDate");
resetField("enteredTitle");
};
};
// NewExpense.tsx
const NewExpense = (props) => {
const saveExpenseDataHandler = (enteredExpenseData) => {
const expenseData = {
...enteredExpenseData,
id: Math.random().toString(),
};
console.log(expenseData); // I want to see this.
props.onAddExpense(expenseData);
};
return (
<div className="new-expense">
<ExpenseForm onSaveExpenseData={saveExpenseDataHandler} />
</div>
);
};
//App.tsx
const App = () => {
const expenses = [
{
id: "xxxx",
title: "xxx",
amount: xxxx,
date: new Date(2022, 5, 16),
},
];
const addExpenseHandler = (expense) => {
console.log(expense);
};
return (
<div>
<NewExpense onAddExpense={addExpenseHandler} />
</div>
);
};
I'm using react with typescript in Udemy Course.
I want to send onSaveExpenseData in NewExpense.tsx to ExpenseForm.tsx.
How do I define type onSaveExpenseData in interface?
Also I want to using reset
I tried to onSaveExpenseData:()=>void, but it doesn't work.
You can export a type for expense.
export interface Expense {
id: string;
title: string;
amount: number;
date: Date;
}
Then in your function you can type expense
const addExpenseHandler = (expense: Expense) => {
console.log(expense);
};
In your NewExpense.tsx you can type its props to this:
interface NewExpenseProps {
onAddExpense: (e: Expense) => void;
}
export const NewExpense = ({ onAddExpense }: NewExpenseProps) => {
const saveExpenseDataHandler = (enteredExpenseData: Expense) => {
const expenseData = {
...enteredExpenseData,
id: Math.random().toString(),
};
console.log(expenseData); // I want to see this.
onAddExpense(expenseData);
};
return (
<div className="new-expense">
<ExpenseForm onSaveExpenseData={saveExpenseDataHandler} />
</div>
);
};
In your expense form you can do the same thing:
interface ExpenseFormProps {
onSaveExpenseData: (v: Expense) => void;
}
export const ExpenseForm = ({ onSaveExpenseData }: ExpenseFormProps) => {
console.log(onSaveExpenseData);
return <div>ExpenseForm</div>;
};
You need to define your props on the react component.
Also the type you wrote was almost correct, but it takes 1 argument that you were missing.
The easiest way to do this is like this:
import { FC } from 'react';
interface Props {
onSaveExpenseData: (_: Expense) => void
}
const ExpenseForm: FC<Props> = ...your current function
FC is the react type for FunctionalComponent. It will define the props and return value for you. The < Props > is a generic you pass to this type. It looks pretty complex but you don't have to fully understand it to use it.

Next.js remember last path with old query after changing sorting options

I have strange issue with Next.js. In projet we have categories and sorting options.
We are using this as a two seperate compnents:
type ITagsScreenOwnProps = {
id?: string;
title?: string;
query?: { [key: string]: string | number | boolean };
};
export const TagsScreen = () => {
const router = useRouter();
const params = router.query as ITagsScreenOwnProps;
const dispatch = useDispatch();
const { t } = useTranslation();
const mediaListId = params.id ?? -1;
const mediaListSelector = (state: IAppState) => {
return state.media.mediaList[mediaListId] || {};
};
const mediaList = useSelector<IAppState, IMediaListModel>(mediaListSelector);
const hasMoreItems = mediaList?.TotalCount > mediaList?.Entities?.length;
const isOnFirstPage =
!mediaList?.Filter?.PageNumber || mediaList?.Filter?.PageNumber === 1;
const isOnNextPage =
mediaList?.Filter?.PageNumber && mediaList?.Filter?.PageNumber > 1;
useEffect(() => {
dispatch(
getMediaList({
MediaListId: mediaListId,
QueryParams: [params.query] ? params.query : undefined,
PageSize: 12,
PageNumber: 1,
})
);
}, [router.query, dispatch]);
const getMore = useCallback(() => {
if (mediaList.Filter?.PageNumber) {
dispatch(
getMediaList({
...mediaList.Filter,
MediaListId: mediaListId,
PageSize: 12,
PageNumber: mediaList.Filter.PageNumber + 1,
})
);
}
}, [mediaList.Filter?.PageNumber, dispatch]);
return (
<div className={styles.container}>
<div className={styles.buttonContainer}>
<MediaCategoryDrawer />
<MediaSortDrawer />
</div>
{mediaList.IsLoading && isOnFirstPage ? (
<div className={styles.loader}>
<LoaderSpinner width={75} height={75} />
</div>
) : (
<div className={styles.content}>
<GridComponent
cellPadding={28}
columns={3}
component={{
ComponentTypeCode: ComponentType.List,
CellType: CellType.Frame,
Orientation: Orientation.Grid,
MediaList: mediaList.Entities,
}}
/>
{hasMoreItems && (
<div className={styles.loader}>
{mediaList?.IsLoading && isOnNextPage ? (
<LoaderSpinner width={75} height={75} />
) : (
<MediaButton
icon={<ChevronDown />}
iconElevated
variant="transparent"
onClick={getMore}
>
{t("COMMON__BUTTON_MORE", "Show more")}
</MediaButton>
)}
</div>
)}
</div>
)}
</div>
);
};
export default TagsScreen;
So we have MediasortingDrawer and MediaCatgoriesDrawer
My problem is that, every time do change my sorting option it is using my initial link. So if I enter my screen with http://localhost:3000/category/asset?query=action_C77030b than even if i change action to drama my sorting will go use such link localhost:3000/category/asset?query=action_C77030b&sort=popularity.month
Not sure why this is happening and why Next.js is using old query.
My category drawer looks like this
const options = {
dropdownMatchSelectWidth: 350,
listHeight: 500,
dropdownAlign: { offset: [0, 1] },
className: `${styles.optionName}`,
};
export const MediaCategoryDrawer = () => {
const router = useRouter();
const params = router.query;
const dispatch = useDispatch();
const [optionValue, setOptionValue] = useState<string | undefined>("");
const pageNumber = 1;
const PAGE_SIZE = 20;
const categoriesToShow = pageNumber * PAGE_SIZE;
const { t } = useTranslation();
const mediaCategoriesSelector = (state: IAppState) => {
return state.media.mediaCategories;
};
const mediaCategories = useSelector<IAppState, IMediaCategoryListModel>(
mediaCategoriesSelector
);
useEffect(() => {
dispatch(getMediaCategories());
}, [dispatch]);
const onCategoryChange = useCallback(
(categoryId?: string) => {
const splitId = categoryId?.split("_")[0];
setOptionValue(splitId);
if (categoryId === "All categories") {
router.replace(router.asPath.split("?")[0]);
} else {
router.replace({
search: UrlHelper.joinQueries(params, {
query: categoryId,
}),
});
}
},
[optionValue]
);
return (
<div>
<Select
{...options}
placeholder={t(
"MEDIA_CATEGORY_DRAWER__ALL_GENRES_COLLECTIONS",
"All Genres & Collections"
)}
defaultValue={`${t("SELECT_CATEGORIES", "Categories: ")}${optionValue}`}
value={`${t("SELECT_CATEGORIES", "Categories: ")}${optionValue}`}
onChange={(e) => onCategoryChange(e)}
>
<Option key="All" value="All categories">
{t("ALL_CATEGORIES", "All categories")}
</Option>
{mediaCategories?.Entities?.slice(0, categoriesToShow - 1).map(
(category) => (
<Option key={category.CategoryName} value={category.CategoryId}>
{category.CategoryName}
</Option>
)
)}
</Select>
</div>
);
};
and sorting drawer looks like this
const IMediaSortOptions = [
{ name: "SELECT__OPTION_POPULAR" },
{ name: "SELECT__OPTION_RECENTLY_ADDED" },
{ name: "SELECT__OPTION_ALPHABETICAL" },
{ name: "SELECT__OPTION_YEAR" },
];
const options = {
dropdownMatchSelectWidth: 350,
listHeight: 500,
dropdownAlign: { offset: [0, 1] },
};
export const MediaSortDrawer = () => {
const { t, i18n } = useTranslation();
const router = useRouter();
const params = router.query;
const [optionValue, setOptionValue] = useState<string | undefined>("");
const langValue = i18n.language;
console.log("params: ", router);
const onSortChange = useCallback(
(sortId?: string) => {
let linkId = sortId;
setOptionValue(sortId);
switch (sortId) {
case SortTypes.ALPHABETICAL:
linkId = `localized.${langValue}.title`;
break;
case SortTypes.YEAR:
linkId = "productionYear";
break;
case SortTypes.POPULAR:
linkId = "popularity.month";
break;
case SortTypes.RECENTLY_ADDED:
linkId = "created";
break;
}
router.replace({
search: UrlHelper.joinQueries(params, {
sort: linkId,
}),
});
},
[langValue]
);
return (
<div>
<Select
{...options}
defaultValue={`${t("SELECT__SORT")}${optionValue}`}
value={`${t("SELECT__SORT")}${optionValue}`}
onChange={(e) => onSortChange(e)}
>
{IMediaSortOptions.map((sortOption) => (
<Option key={`${t(sortOption.name)}`} value={`${t(sortOption.name)}`}>
{t(sortOption.name)}
</Option>
))}
</Select>
</div>
);
};

React not working with memo and tree structure

I've been going at it for 2 days and cannot figure it out :(
I have a tree-like conversation as you can see in the screenshot.
When a person types something in an empty input field, the Message is then added to the reducer array "conversationsData.messages". When that happens, the Replies component of each Message is listening to changes to only the ~3 replies of that message. If the replies change, then the Replies should rerender. Buuut... The problem is that every single Reply component, and thus every single message is being re-rendered which is causing lag.
Can you please help me get the memo to work properly?
ConversationManager/Conversation/Message.tsx
import React, { FunctionComponent, ReactElement, useRef, useState, useEffect, useMemo } from 'react'
import { useDispatch, useStore, useSelector } from 'react-redux'
import Autosuggest, { OnSuggestionSelected, ChangeEvent } from 'react-autosuggest'
import colors from '#common/colors'
import { updateMessage, removeMessage } from '#reducers/conversationsData'
import usePrevious from 'react-hooks-use-previous'
import MessageInterface, { MessageInitState } from '#interfaces/message'
import { RootState } from '#reducers/rootReducer'
import NewReply from './NewReply'
import { StyleSheet } from '#interfaces/common'
interface IMessageProps {
origMessage: MessageInterface,
isSubClone: boolean,
firstRender: boolean, // It's firstRender=true if we're rendering the message for the first time, "false" if it's a dynamic render
isStarter?: boolean
}
const MessageFunc = ({ origMessage, isSubClone, firstRender }: IMessageProps): ReactElement | null => {
if(!origMessage.id){
return null
}
const dispatch = useDispatch()
const store = useStore()
const state: RootState = store.getState()
const [inputSuggestions, setInputSuggestions] = useState<MessageInterface[]>([])
const [inputWidth, setInputWidth] = useState(0)
const $invisibleInput = useRef<HTMLInputElement>(null)
const isFirstRun = useRef(true)
const [localMessage, setLocalMessage] = useState<MessageInterface>(MessageInitState)
const previousLocalMessage = usePrevious<MessageInterface>(localMessage, MessageInitState)
useEffect(() => {
isFirstRun.current = true
setLocalMessage(origMessage)
}, [origMessage])
useEffect(() => {
if(!localMessage.id) return
if(isFirstRun.current == true){
setupInputWidth()
isFirstRun.current = false
}
if(previousLocalMessage.text != localMessage.text){
setupInputWidth()
}
if(previousLocalMessage.cloneId != localMessage.cloneId){
setupIfMessageClone()
}
}, [localMessage])
const characterMessages = state.conversationsData.messages.filter((m) => {
return m.characterId == origMessage.characterId
})
const parent: MessageInterface = characterMessages.find((m) => {
return m.id == origMessage.parentId
}) || MessageInitState
const setupIfMessageClone = () => { // This function is only relevant if this message is a clone of another one
if(!localMessage.cloneId) return
const cloneOf = characterMessages.find((m) => {
return m.id == localMessage.cloneId
}) || MessageInitState
setLocalMessage({
...localMessage,
text: cloneOf.text
})
}
const setupInputWidth = () => {
let width = $invisibleInput.current ? $invisibleInput.current.offsetWidth : 0
width = width + 30 // Let's make the input width a bit bigger
setInputWidth(width)
}
const _onFocus = () => {
// if(!localMessage.text){ // No message text, create a new one
// dispatch(updateMessage(localMessage))
// }
}
const _onBlur = () => {
if(localMessage.text){
dispatch(updateMessage(localMessage))
}
// No message, delete it from reducer
else {
dispatch(removeMessage(localMessage))
}
}
const _onChange = (event: React.FormEvent, { newValue }: ChangeEvent): void => {
setLocalMessage({
...localMessage,
cloneId: '',
text: newValue
})
}
const _suggestionSelected: OnSuggestionSelected<MessageInterface> = (event, { suggestion }) => {
setLocalMessage({
...localMessage,
cloneId: suggestion.id
})
}
const getSuggestions = (value: string): MessageInterface[] => {
const inputVal = value.trim().toLowerCase()
const inputLen = inputVal.length
return inputLen === 0 ? [] : characterMessages.filter(message =>
message.text.toLowerCase().slice(0, inputLen) === inputVal
)
}
if(!localMessage.id){
return null
}
else {
return (
<>
<li>
<div>
<Autosuggest
suggestions={inputSuggestions}
onSuggestionsFetchRequested={({ value }) => setInputSuggestions(getSuggestions(value))}
onSuggestionsClearRequested={() => setInputSuggestions([])}
getSuggestionValue={(suggestion) => suggestion.text}
onSuggestionSelected={_suggestionSelected}
renderSuggestion={(suggestion) => (
<div>
{suggestion.text}
</div>
)}
theme={{ ...autoSuggestTheme, input: {
...styles.input,
width: inputWidth,
borderBottomColor: localMessage.cloneId ? colors.purple : 'default',
borderBottomWidth: localMessage.cloneId ? 2 : 1
} }}
inputProps={{
value: localMessage.text,
onChange: _onChange,
onBlur: _onBlur,
onFocus: _onFocus,
className: 'form-control',
disabled: isSubClone
}}
/>
<span style={styles.invisibleSpan} ref={$invisibleInput}>{localMessage.text}</span>
</div>
<ul className="layer">
<Replies parentMessage={localMessage} isSubClone={isSubClone} />
</ul>
</li>
</>
)
}
}
const Message = React.memo(MessageFunc)
// const Message = MessageFunc
interface IRepliesProps {
parentMessage: MessageInterface,
isSubClone: boolean
}
const RepliesFunc: FunctionComponent<IRepliesProps> = ({
parentMessage, isSubClone
}: IRepliesProps): ReactElement | null => {
const previousParentMessage = usePrevious<MessageInterface>(parentMessage, MessageInitState)
const isFirstRun = useRef(true)
const replies: MessageInterface[] = useSelector((state: RootState) => state.conversationsData.messages.filter((m) => {
// If parent is regular message
if(!parentMessage.cloneId){
return m.parentId == parentMessage.id && m.characterId == parentMessage.characterId
}
// If parent is a clone, then replies need to come from the main clone
// else {
// return m.parentId == parentMessage.cloneId
// }
}))
if(replies.length){
return (
<>
{console.log('rendering Replies...')}
{replies.map((reply) => {
return (
<Message
origMessage={reply}
key={reply.id}
isSubClone={parentMessage.cloneId ? true : isSubClone}
firstRender={true}
/>
)
})}
{parentMessage.text && !parentMessage.cloneId && !isSubClone && (
<NewReply
parentMessage={parentMessage}
/>
)}
</>
)
}
else {
return null
}
}
// const Replies = React.memo(RepliesFunc)
const Replies = RepliesFunc
export default Message
const styles: StyleSheet = {
input: {
width: 0,
padding: 0,
paddingLeft: 10,
lineHeight: 25,
height: 25,
fontSize: 11,
boxShadow: 'none',
minWidth: 22
},
clone: {
borderBottomWidth: 2,
borderBottomColor: colors.purple
},
invisibleSpan: { // This is used for getting text width of input (for dynamic resizing of input fields)
opacity: 0,
position: 'absolute',
left: -9999,
top: -9999,
fontSize: 11
}
}
const autoSuggestTheme: StyleSheet = {
container: {
position: 'relative'
},
inputOpen: {
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0
},
suggestionsContainer: {
display: 'none'
},
suggestionsContainerOpen: {
display: 'block',
position: 'absolute',
top: 25,
width: '100%',
minWidth: 400,
border: '1px solid #aaa',
backgroundColor: '#fff',
fontWeight: 300,
fontSize: 11,
borderBottomLeftRadius: 4,
borderBottomRightRadius: 4,
zIndex: 2
},
suggestionsList: {
margin: 0,
padding: 0,
listStyleType: 'none'
},
suggestion: {
cursor: 'pointer',
padding: '5px 10px'
},
suggestionHighlighted: {
backgroundColor: '#ddd'
}
}
reducers/ConversationsData.ts
import { createSlice, PayloadAction } from '#reduxjs/toolkit'
import MessageInterface from '#interfaces/message'
import axios, { AxiosRequestConfig } from 'axios'
import conversationsDataJSON from '#data/conversationsData.json'
import { AppThunk } from '#reducers/store'
import _ from 'lodash'
interface IInitialState {
loaded: boolean,
messages: MessageInterface[]
}
export const initialState: IInitialState = {
loaded: false,
messages: []
}
export const charactersDataSlice = createSlice({
name: 'conversationsData',
initialState,
reducers: {
loadData: (state, action: PayloadAction<MessageInterface[]>) => {
return state = {
loaded: true,
messages:action.payload
}
},
add: (state, { payload }: PayloadAction<{message: MessageInterface}>) => {
state.messages.push(payload.message)
},
edit: (state, { payload }: PayloadAction<{message: MessageInterface}>) => {
const updatedConversations = state.messages.map(message => {
if(message.id == payload.message.id && message.characterId == payload.message.characterId){
return message = {
...payload.message,
text: payload.message.cloneId ? '' : payload.message.text // If there's a cloneId, don't save the text since the text comes from the clone parent
}
}
else {
return message
}
})
state.messages = updatedConversations
},
remove: (state, { payload }: PayloadAction<{message: MessageInterface}>) => {
_.remove(state.messages, (message) => {
return message.id == payload.message.id && message.characterId == payload.message.characterId
})
}
}
})
const { actions, reducer } = charactersDataSlice
const { loadData, edit, add, remove } = actions
// Thunk actions
// ---------
const loadConversationsData = (): AppThunk => {
return dispatch => {
const conversationsData: MessageInterface[] = conversationsDataJSON
dispatch(loadData(conversationsData))
}
}
const updateMessage = (message: MessageInterface): AppThunk => {
return (dispatch, getState) => {
const existingMessage: MessageInterface | undefined = getState().conversationsData.messages.find((m: MessageInterface) => {
return m.id == message.id && m.characterId == message.characterId
})
// If message exists, update it
if(existingMessage){
dispatch(edit({
message: message
}))
}
// else create a new message
else {
dispatch(add({
message: message
}))
}
setTimeout(() => {
dispatch(saveConversationsData())
}, 10)
}
}
const removeMessage = (message: MessageInterface): AppThunk => {
return (dispatch, getState) => {
const children: MessageInterface[] | [] = getState().conversationsData.messages.filter((m: MessageInterface) => {
return m.parentId == message.id && m.characterId == message.characterId
})
const hasChildren = children.length > 0
// If message has children, stop
if(hasChildren){
alert('This message has children. Will not kill this message. Remove the children first.')
}
// Otherwise, go ahead and kill message
else {
dispatch(remove({
message: message
}))
setTimeout(() => {
dispatch(saveConversationsData())
}, 10)
}
}
}
export const saveConversationsData = (): AppThunk => {
return (dispatch, getState) => {
const conversationsMessages = getState().conversationsData.messages
const conversationsMessagesJSON = JSON.stringify(conversationsMessages, null, '\t')
const options: AxiosRequestConfig = {
method: 'POST',
url: 'http://localhost:8888/api/update-conversations.php',
headers: { 'content-type': 'application/json; charset=UTF-8' },
data: conversationsMessagesJSON
}
axios(options)
.catch(error => console.error('Saving conversationsData error:', error))
}
}
// Exporting it all
// ---------
export { loadConversationsData, updateMessage, removeMessage }
export default reducer
interfaces/message.ts
export default interface MessageInterface {
id: string,
characterId: string,
text: string,
cloneId: string,
parentId: string
}
export const MessageInitState: MessageInterface = {
id: '',
characterId: '',
text: '',
cloneId: '',
parentId: ''
}
Because your selector uses Array.prototype.filter you create a new array every time the messages array changes for each component.
If you would store the data in the state as nested data you can prevent this from happening. For example: {id:1, message:'hello', replies:[{id:2, message:'world', replies:[]}]}.
A simpler way is to use the memoization of reselect to see if each element in the filtered array is the same as it was last time. This will require more resources than the nested solution as it will perform the filter on every change for every branch but won't re render needlessly.
Here is the simple example:
const { Provider, useDispatch, useSelector } = ReactRedux;
const { createStore, applyMiddleware, compose } = Redux;
const { createSelector, defaultMemoize } = Reselect;
const initialState = { messages: [] };
//action types
const ADD = 'ADD';
//helper crating id for messages
const id = ((id) => () => ++id)(0);
//action creators
const add = (parentId, message) => ({
type: ADD,
payload: { parentId, message, id: id() },
});
const reducer = (state, { type, payload }) => {
if (type === ADD) {
const { parentId, message, id } = payload;
return {
...state,
messages: state.messages.concat({
id,
parentId,
message,
}),
};
}
return state;
};
//selectors
const selectMessages = (state) => state.messages;
//curry creating selector function that closes over message id
// https://github.com/amsterdamharu/selectors
const createSelectMessageById = (messageId) =>
createSelector([selectMessages], (messages) =>
messages.find(({ id }) => id === messageId)
);
//used to check each item in the array is same as last
// time the function was called
const createMemoizeArray = (array) => {
const memArray = defaultMemoize((...array) => array);
return (array) => memArray.apply(null, array);
};
//curry creating selector function that closes over parentId
// https://github.com/amsterdamharu/selectors
const createSelectMessagesByParentId = (parentId) => {
//memoizedArray([1,2,3]) === memoizedArray([1,2,3]) is true
//https://github.com/reduxjs/reselect/issues/451#issuecomment-637521511
const memoizedArray = createMemoizeArray();
return createSelector([selectMessages], (messages) =>
memoizedArray(
messages.filter((m) => m.parentId === parentId)
)
);
};
//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)
)
)
);
const AddMessage = ({ addMessage }) => {
const [reply, setReply] = React.useState('');
return (
<div>
<label>
message:
<input
type="text"
onChange={(e) => setReply(e.target.value)}
value={reply}
/>
</label>
<button onClick={() => addMessage(reply)}>Add</button>
</div>
);
};
const AddMessageContainer = React.memo(
function AddMessageContainer({ messageId }) {
const dispatch = useDispatch();
const addMessage = React.useCallback(
(message) => dispatch(add(messageId, message)),
//dispatch in deps should not be needed but
// my linter still complains about it
[dispatch, messageId]
);
return <AddMessage addMessage={addMessage} />;
}
);
const Message = ({ message, replies }) => {
console.log('in message render', message && message.message);
return (
<div>
{message ? <h1>{message.message}</h1> : ''}
{Boolean(replies.length) && (
<ul>
{replies.map(({ id }) => (
<MessageContainer key={id} messageId={id} />
))}
</ul>
)}
{/* too bad optional chaining (message?.id) does not work on SO */}
<AddMessageContainer
messageId={message && message.id}
/>
</div>
);
};
const MessageContainer = React.memo(
function MessageContainer({ messageId }) {
const selectMessage = React.useMemo(
() => createSelectMessageById(messageId),
[messageId]
);
const selectReplies = React.useMemo(
() => createSelectMessagesByParentId(messageId),
[messageId]
);
const message = useSelector(selectMessage);
const replies = useSelector(selectReplies);
return <Message message={message} replies={replies} />;
}
);
const App = () => {
return <MessageContainer />;
};
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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reselect/4.0.0/reselect.min.js"></script>
<div id="root"></div>

Is it a valid way to write redux actions and reducer?

I've built a cards war game. I'm new to redux and wonder if I use it the correct way, especially when I declare actions in the Game and Main components, and use action's payloads as callbacks to update the state. Also, It feels like a lot of code for a small app. Maybe you can help guys and give me some insights if i'm doing it the wrong way and why, thanks. I put here the relevant components and the full code is here:
https://github.com/morhaham/cards-war-redux
store.js:
import { createStore } from "redux";
const state = {
game_ready: false,
cards: [],
player: { name: "", cards: [], points: 0 },
computer: { name: "computer", cards: [], points: 0 },
};
const reducer = (state, action) => {
switch (action.type) {
case "INIT_GAME_CARDS":
return action.payload(state);
case "UPDATE_PLAYER_NAME":
return action.payload(state);
case "SET_GAME_READY":
return action.payload(state);
case "DIST_CARDS":
return action.payload(state);
case "SET_NEXT_CARDS":
return action.payload(state);
case "INCREASE_POINTS":
return action.payload(state);
case "RESET_GAME":
return action.payload(state);
default:
return state;
}
};
const store = createStore(reducer, state);
export default store;
Main.js:
import React, { useEffect } from "react";
import { Button } from "#material-ui/core";
import { useHistory } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { NUM_OF_CARDS, MAX_CARD_VALUE } from "./constants";
import { shuffle } from "./helpers";
// action creator to initialize the game
const initGameCards = () => ({
type: "INIT_GAME_CARDS",
payload: (state) => {
// creates an array of size 52 filled with 1..13 four times
const cards = Array(NUM_OF_CARDS / MAX_CARD_VALUE)
.fill(
Array(13)
.fill()
.map((_, i) => i + 1)
)
.flat();
// shuffle the cards
shuffle(cards);
return {
...state,
cards,
};
},
});
// action creator to control the player's name
const updatePlayerName = (name) => ({
type: "UPDATE_PLAYER_NAME",
payload: (state) => ({
...state,
player: { ...state.player, name: name },
}),
});
const setGameReady = () => ({
type: "SET_GAME_READY",
payload: (state) => ({
...state,
game_ready: true,
}),
});
function Main() {
const history = useHistory();
const dispatch = useDispatch();
const player = useSelector(({ player }) => player);
// const game_ready = useSelector(({ game_ready }) => game_ready);
const handleClick = React.useCallback(
(e) => {
e.preventDefault();
if (player.name) {
dispatch(setGameReady());
history.replace("./game");
}
},
[dispatch, player.name]
);
useEffect(() => {
dispatch(initGameCards());
}, []);
const handleChange = React.useCallback((e) => {
const target = e.target;
const val = target.value;
switch (target.id) {
case "playerName":
dispatch(updatePlayerName(val));
break;
default:
break;
}
});
return (
<div>
{/* check for valid input */}
<form>
<label htmlFor="playerName">
<h1 className="text-blue-800 text-5xl text-shadow-lg mb-3">
Ready for war
</h1>
</label>
<input
className="border focus:ring-2 focus:outline-none"
id="playerName"
required
onChange={handleChange}
placeholder="Enter your name"
type="text"
value={player.name}
/>
{!player.name ? (
<p className="text-red-700">Please fill the field</p>
) : (
""
)}
<Button
onClick={handleClick}
type="submit"
color="primary"
variant="contained"
>
Start
</Button>
</form>
</div>
);
}
export default Main;
Game.js:
import { Button } from "#material-ui/core";
import React from "react";
import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useHistory } from "react-router-dom";
import { NUM_OF_CARDS } from "./constants";
import { shuffle } from "./helpers";
// action creator to distribute the cards at the beginning of the game
const distCards = () => ({
type: "DIST_CARDS",
payload: (state) => {
const cards = [...state.cards];
shuffle(cards);
const computer_cards = cards.slice(0, NUM_OF_CARDS / 2);
const player_cards = cards.slice(NUM_OF_CARDS / 2);
const computer_current_card = computer_cards.pop();
const player_current_card = player_cards.pop();
return {
...state,
cards,
// distributes cards evenly
computer: {
...state.computer,
cards: computer_cards,
current_card: computer_current_card,
points: 0,
},
player: {
...state.player,
cards: player_cards,
current_card: player_current_card,
points: 0,
},
};
},
});
const setNextCards = () => ({
type: "SET_NEXT_CARDS",
payload: (state) => {
let [computer_cards, player_cards] = [
[...state.computer.cards],
[...state.player.cards],
];
const [computer_next_card, player_next_card] = [
computer_cards.pop(),
player_cards.pop(),
];
return {
...state,
player: {
...state.player,
cards: player_cards,
current_card: player_next_card,
},
computer: {
...state.computer,
cards: computer_cards,
current_card: computer_next_card,
},
};
},
});
const pointsIncreament = () => ({
type: "INCREASE_POINTS",
payload: (state) => {
const [player_current_card, computer_current_card] = [
state.player.current_card,
state.computer.current_card,
];
return {
...state,
player: {
...state.player,
points:
player_current_card > computer_current_card
? state.player.points + 1
: state.player.points,
},
computer: {
...state.computer,
points:
player_current_card < computer_current_card
? state.computer.points + 1
: state.computer.points,
},
};
},
});
function Game() {
const player = useSelector(({ player }) => player);
const computer = useSelector(({ computer }) => computer);
const game_ready = useSelector(({ game_ready }) => game_ready);
const dispatch = useDispatch();
const history = useHistory();
const handleReset = React.useCallback(() => {
dispatch(distCards());
}, [dispatch]);
useEffect(() => {
if (game_ready) {
dispatch(distCards());
} else {
history.replace("/");
}
}, [game_ready]);
useEffect(() => {
if (player.current_card && computer.current_card) {
dispatch(pointsIncreament());
}
}, [player.current_card, computer.current_card]);
const handleClick = React.useCallback(() => {
dispatch(setNextCards());
});
return (
<div className="flex justify-center">
<div className="flex flex-col">
<div>
<div>{player.name}</div>
<div>Points: {player.points}</div>
<div>{player.current_card}</div>
</div>
<div>
<div>{computer.current_card}</div>
<div>Points: {computer.points}</div>
<div>{computer.name}</div>
</div>
{!player.cards.length || !computer.cards.length ? (
<Button
onClick={handleReset}
type="submit"
color="primary"
variant="contained"
>
Again?
</Button>
) : (
<Button
onClick={handleClick}
type="submit"
color="primary"
variant="contained"
>
next
</Button>
)}
<div>
{!player.cards.length || !computer.cards.length ? (
player.points === computer.points ? (
<h2>It's a tie</h2>
) : player.points > computer.points ? (
<h2>{player.name} won!</h2>
) : (
<h2>{computer.name} won!</h2>
)
) : (
""
)}
</div>
</div>
</div>
);
}
export default Game;

Resources