Making a separated component disappear with useReducer - reactjs

I'm currently learning the React Hooks feature so I created a small experiment where an invisible(unmounted) box would appear if the button is clicked; if the box is visible and you click on anywhere on the page except the box, the box would disappear. I'm struggling making the box disappear and I don't know what's causing the bug.
Initial state and the reducer:
const initialState = { visible: false };
const reducer = (state, action) => {
switch (action.type) {
case 'show':
return { visible: true };
case 'hide':
return { visible: false };
default:
return state;
}
};
The Box component:
function Box() {
const [state, dispatch] = useReducer(reducer, initialState);
const boxElement = useRef(null);
const boxStyle = {
width: '200px',
height: '200px',
background: 'blue'
};
function hideBox(e) {
if(!boxElement.current.contains(e.target)) {
dispatch({ type: 'hide' });
}
}
useEffect(() => {
window.addEventListener('click', hideBox);
return () => {
window.removeEventListener('click', hideBox);
}
});
return <div style={boxStyle} ref={boxElement} />
}
Main:
function App() {
const [state, dispatch] = useReducer(reducer, initialState);
function showBox() {
dispatch({ type: 'show' });
}
return (
<section>
{ state.visible && <Box /> }
<button onClick={showBox}>Show box</button>
</section>
)
}

You are using two instances of useReducer whereas you only need to have one at the App component level and pass dispatch as a prop to Box otherwise you would only be updating the state that is used by the useReducer in Box and not the state in App component
function App() {
const [state, dispatch] = useReducer(reducer, initialState);
function showBox() {
dispatch({ type: 'show' });
}
return (
<section>
{ state.visible && <Box dispatch={dispatch}/> }
<button onClick={showBox}>Show box</button>
</section>
)
}
Box.js
function Box({dispatch}) {
const boxElement = useRef(null);
const boxStyle = {
width: '200px',
height: '200px',
background: 'blue'
};
function hideBox(e) {
if(!boxElement.current.contains(e.target)) {
dispatch({ type: 'hide' });
}
}
useEffect(() => {
window.addEventListener('click', hideBox);
return () => {
window.removeEventListener('click', hideBox);
}
});
return <div style={boxStyle} ref={boxElement} />
}
Working demo

Related

React: How update children component hidden to show?

I have a problem that children component does not update hidden status when project is selected, it should then display all the the tasks included in selected projects. How ever when getTasks is done and it updates hidden state to false and it passes state to children component props but children component never reintialize select component and remains hidden. What I need to change to make my selectbox class RtSelect to display hidden state changes?
My master component:
import React, { useState, useEffect, useRef } from 'react';
import RtSelect from './RtSelect';
import api, { route } from "#forge/api";
function Projects() {
const projRef = useRef();
const taskRef = useRef();
const [projects, setProjects] = useState(undefined)
const [tasks, setTasks] = useState(undefined)
const [projectid, setProjectid] = useState(undefined)
const [taskid, setTaskid] = useState(undefined)
const [hidden, setHidden] = useState(true)
//haetaan atlasiansita projectit array
useEffect(() => {
let loadedProject = true;
// declare the async data fetching function
const fetchProjects = async () => {
// get the data from the api
const response = await api.asUser().requestJira(route`/rest/api/3/project`, {
headers: {
'Accept': 'application/json'
}
});
const data = await response.json();
//Mapataa hausta tarvittavat tiedot
const result = data.map(function (item) {
console.log('test');
return [
{
label: item.name,
value: item.id,
avatar: item.avatarUrls['16x16']
}
]
})
// set state with the result if `isSubscribed` is true
if (loadedProject) {
setProjects(result);
}
}
//asetetaan state selectbox muutokselle
// call the function
fetchProjects()
// make sure to catch any error
.catch(console.error);;
// cancel any future `setData`
return () => loadedProject = false;
}, [param])
const getTasks = async (p) => {
// get the data from the api
const response = await api.asUser().requestJira(route`/rest/api/3/issuetype/project?projectId={p}`, {
headers: {
'Accept': 'application/json'
}
});
const data = await response.json();
//Mapataa hausta tarvittavat tiedot
const result = data.map(function (item) {
console.log('test');
return [
{
value: item.id,
label: item.description,
avatar: item.iconUrl
}
]
})
setTasks(result)
setHidden(false)
}
useEffect(() => {
projRef.current.addEventListener("onChange", (e) => {
setProjectid(e.target.value)
console.log("Project select boxin arvo on: " + e.target.value);
getTasks(projectid)
});
});
useEffect(() => {
taskRef.current.addEventListener("onChange", (e) => {
setTaskid(e.target.value)
console.log("Select task boxin arvo on: " + e.target.value);
});
});
return (
<div>
<div className='projects'>
<RtSelect info="Choose project:" options={projects} hidden={false} ref={projRef} />
</div>
<div className='tasks'>
<RtSelect info="Choose Task:" options={tasks} hidden={hidden} ref={taskRef} />
</div>
</div>
);
}
export default Projects
Here is my RtSelect class code:
import React from "react";
import Select from "react-select";
class RtSelect extends React.Component {
state = {
info: this.props.info,
options: this.props.options,
hidden: this.props.hidden,
menuIsOpen: '',
menuWidth: "",
IsCalculatingWidth: ''
};
constructor(props) {
super(props);
this.selectRef = props.ref
this.onMenuOpen = this.onMenuOpen.bind(this);
this.setData = this.setData.bind(this);
}
componentDidMount() {
if (!this.state.menuWidth && !this.state.isCalculatingWidth) {
setTimeout(() => {
this.setState({IsCalculatingWidth: true});
// setIsOpen doesn't trigger onOpenMenu, so calling internal method
this.selectRef.current.select.openMenu();
this.setState({menuIsOpen: true});
}, 1);
}
}
onMenuOpen() {
if (!this.state.menuWidth && this.state.IsCalculatingWidth) {
setTimeout(() => {
const width = this.selectRef.current.select.menuListRef.getBoundingClientRect()
.width;
this.setState({menuWidth: width});
this.setState({IsCalculatingWidth: false});
// setting isMenuOpen to undefined and closing menu
this.selectRef.current.select.onMenuClose();
this.setState({menuIsOpen: undefined});
}, 1);
}
}
styles = {
menu: (css) => ({
...css,
width: "auto",
...(this.state.IsCalculatingWidth && { height: 0, visibility: "hidden" })
}),
control: (css) => ({ ...css, display: "inline-flex " }),
valueContainer: (css) => ({
...css,
...(this.state.menuWidth && { width: this.state.menuWidth })
})
};
setData (props) {
if (props.info) {
this.setState({
info: props.info
})
}
if (props.options) {
this.setState({
options: props.options
})
}
if (props.hidden) {
this.setState({
hidden: props.hidden
})
}
}
render () {
return (
<div style={{ display: "flex" }}>
<div style={{ margin: "8px" }}>{this.state.info}</div>
<div style={{minWidth: "200px"}}>
<Select
ref={this.selectRef}
onMenuOpen={this.onMenuOpen}
options={this.state.options}
menuIsOpen={this.state.menuIsOpen}
styles={this.styles}
isDisabled={this.state.hidden}
formatOptionLabel={(options) => (
<div className="select-option" style={{ display: "flex", menuWidth: "200px"}}>
<div style={{ display: "inline", verticalAlign: "center" }}>
<img src={options.avatar} width="30px" alt="Avatar" />
</div>
<div style={{ display: "inline", marginLeft: "10px" }}>
<span>{options.label}</span>
</div>
</div>
)}
/>
</div>
</div>
);
}
}
export default RtSelect;
Ok I found from other examples that I can use the ref to acces child method so here is they way to update component:
useEffect(() => {
projRef.current.addEventListener("onChange", (e) => {
setProjectid(e.target.value)
console.log("Project select boxin arvo on: " + e.target.value);
getTasks(projectid)
//Using RtSelect taskRef to locate children component method to update component
taskRef.current.setData({hidden: false})
});
});

MUI Snackbar Rerenders Again When Store Gets Updated

I am using MUI Snackbar in my root component (using STORE) and it appeared more than once because when store updated, the component gets re-rendered again
Snackbar Component:
export type BaseLayoutProps = {
children: ReactNode;
};
const Toast: FC<BaseLayoutProps> = ({ children }) => {
const [state,] = useSetupsStore();
const { toastProps } = state;
const [open, setOpen] = useState(toastProps?.toastState);
useEffect(() => {
if (toastProps) {
setOpen(toastProps.toastState);
}
}, [toastProps]);
const handleClose = (
event: React.SyntheticEvent | Event,
reason?: string
) => {
if (reason === "clickaway") {
return;
}
setOpen(false);
};
return (
<>
<Snackbar
theme={unityTheme}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
autoHideDuration={
toastProps?.toastLength ? toastProps.toastLength : 5000
}
variant={toastProps?.variant}
open={open}
onClose={handleClose as any}
message={toastProps?.toastMessage ? toastProps?.toastMessage : "Testing"}
TransitionComponent={(props) => <Slide {...props} direction="up" />}
/>
{children}
</>
)
}
export default Toast;
SnackBar Action:
export const setToast = (toastProps: IToast): StoreActionType => {
if (toastProps?.toastState) {
return { type: ActionTypes.SET_TOAST, payload: toastProps };
}
};
Snackbar Reducer
export const ToastReducer = (
state: IToast,
action: StoreActionType
): IToast => {
switch (action.type) {
case ActionTypes.SET_TOAST:
return {
...state,
toastState: (action.payload as IToast).toastState,
variant: (action.payload as IToast).variant,
toastMessage: (action.payload as IToast).toastMessage,
// toastLength: (action.payload as IToast).toastLength,
}
default:
return state
}
};
Dispatch Code:
dispatch(
setToast({
toastMessage: ToastMessages.Record_Added_Success,
toastState: true,
})
);
I'm 100% sure that it is dispatching only once but when another api get called(store get updated) it again comes to my reducer and root component get re-rendered again due to which it appeared twice. I have also return null in default case in reducer like this:
export const ToastReducer = (
state: IToast,
action: StoreActionType
): IToast => {
switch (action.type) {
case ActionTypes.SET_TOAST:
console.log("inside case",action);
return {
...state,
toastState: (action.payload as IToast).toastState,
variant: (action.payload as IToast).variant,
toastMessage: (action.payload as IToast).toastMessage,
}
default:
return null
}
In above case Snackbar appeared only once for a milisecond & then disappeared again
How to resolve this?

How can I implement not only one but multi setting toggles?

I use Shopify Polaris's setting toggle.https://polaris.shopify.com/components/actions/setting-toggle#navigation
And I want to implement not only one but multi setting toggles.But I don't want to always duplicate same handleToggle() and values(contentStatus, textStatus) like below the sandbox A,B,C...
import React, { useCallback, useState } from "react";
import { SettingToggle, TextStyle } from "#shopify/polaris";
export default function SettingToggleExample() {
const [activeA, setActiveA] = useState(false);
const [activeB, setActiveB] = useState(false);
const handleToggleA = useCallback(() => setActiveA((active) => !active), []);
const handleToggleB = useCallback(() => setActiveB((active) => !active), []);
const contentStatusA = activeA ? "Deactivate" : "Activate";
const contentStatusB = activeB ? "Deactivate" : "Activate";
const textStatusA = activeA ? "activated" : "deactivated";
const textStatusB = activeB ? "activated" : "deactivated";
const useHandleToggle = (active, setActive) => {
const handleToggle = useCallback(() => setActive((active) => !active), []);
const contentStatus = active ? "Disconnect" : "Connect";
const textStatus = active ? "connected" : "disconnected";
handleToggle();
return [contentStatus, textStatus];
};
useHandleToggle(activeA, setActiveA);
return (
<>
<SettingToggle
action={{
content: contentStatusA,
onAction: handleToggleA
}}
enabled={activeA}
>
This setting is <TextStyle variation="strong">{textStatusA}</TextStyle>.
</SettingToggle>
<SettingToggle
action={{
content: contentStatusB,
onAction: handleToggleB
}}
enabled={activeB}
>
This setting is <TextStyle variation="strong">{textStatusB}</TextStyle>.
</SettingToggle>
</>
);
}
https://codesandbox.io/s/vigorous-pine-k0dpib?file=/App.js
So I thought I can use a custom hook. But it's not working. So it would be helpful if you give me some advice.
Using simple Booleans for each toggle
If you combine your active state objects into a single array, then you can update as many settings as you would like dynamically. Here's an example of what that might look like:
import React, { useCallback, useState } from "react";
import { SettingToggle, TextStyle } from "#shopify/polaris";
export default function SettingToggleExample() {
// define stateful array of size equal to number of toggles
const [active, setActive] = useState(Array(2).fill(false));
const handleToggle = useCallback((i) => {
// toggle the boolean at index, i
setActive(prev => [...prev.slice(0,i), !prev[i], ...prev.slice(i+1)])
}, []);
return (
<>
{activeStatuses.map((isActive, index) =>
<SettingToggle
action={{
content: isActive ? "Deactivate" : "Activate",
onAction: () => handleToggle(index)
}}
enabled={isActive}
>
This setting is <TextStyle variation="strong">{isActive ? "activated" : "deactivated"}</TextStyle>.
</SettingToggle>
}
</>
);
}
Of course, you will likely want to add a label to each of these going forward, so it may be better to define a defaultState object outside the function scope and replace the Array(2).fill(false) with it. Then you can have a string label property for each toggle in addition to a boolean active property which can be added next to each toggle in the .map(...).
With labels added for each toggle
Per your follow up, here is the implementation also found in the CodeSandbox for a state with labels for each toggle (including here on the answer to protect against link decay):
import React, { useCallback, useState } from "react";
import { SettingToggle, TextStyle } from "#shopify/polaris";
const defaultState = [
{
isActive: false,
label: "A"
},
{
isActive: false,
label: "B"
},
{
isActive: false,
label: "C"
}
];
export default function SettingToggleExample() {
const [active, setActive] = useState(defaultState);
const handleToggle = useCallback((i) => {
// toggle the boolean at index, i
setActive((prev) => [
...prev.slice(0, i),
{ ...prev[i], isActive: !prev[i].isActive },
...prev.slice(i + 1)
]);
}, []);
return (
<div style={{ height: "100vh" }}>
{active?.map(({ isActive, label }, index) => (
<SettingToggle
action={{
content: isActive ? "Deactivate" : "Activate",
onAction: () => handleToggle(index)
}}
enabled={isActive}
key={index}
>
This {label} is 
<TextStyle variation="strong">
{isActive ? "activated" : "deactivated"}
</TextStyle>
.
</SettingToggle>
))}
</div>
);
}
My first attempt to refactor would use a parameter on the common handler
const handleToggle = useCallback((which) => {
which === 'A' ? setActiveA((activeA) => !activeA)
: setActiveB((activeB) => !activeB)
},[])
...
<SettingToggle
action={{
content: contentStatusA,
onAction: () => handleToggle('A')
}}
enabled={activeA}
>
It functions, but feels a bit naïve. For something more React-ish, a reducer might be the way to go.
With a reducer
This seems cleaner, and is definitely more extensible if you need more toggles.
function reducer(state, action) {
switch (action.type) {
case "toggleA":
const newValueA = !state.activeA;
return {
...state,
activeA: newValueA,
contentStatusA: newValueA ? "Deactivate" : "Activate",
textStatusA: newValueA ? "activated" : "deactivated"
};
case "toggleB":
const newValueB = !state.activeB;
return {
...state,
activeB: newValueB,
contentStatusB: newValueB ? "Deactivate" : "Activate",
textStatusB: newValueB ? "activated" : "deactivated"
};
default:
throw new Error();
}
}
const initialState = {
activeA: false,
activeB: false,
contentStatusA: "Activate",
contentStatusB: "Activate",
textStatusA: "deactivated",
textStatusB: "deactivated"
};
export default function SettingToggleExample() {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<>
<SettingToggle
action={{
content: state.contentStatusA,
onAction: () => dispatch({type: 'toggleA'})
}}
enabled={state.activeA}
>
This setting is <TextStyle variation="strong">{state.textStatusA}</TextStyle>.
</SettingToggle>
<SettingToggle
action={{
content: state.contentStatusB,
onAction: () => dispatch({type: 'toggleA'})
}}
enabled={state.activeB}
>
This setting is <TextStyle variation="strong">{state.textStatusB}</TextStyle>.
</SettingToggle>
</>
);
}
With a wrapper component
A child component can eliminate the 'A' and 'B' suffixes
function reducer(state, action) {
switch (action.type) {
case "toggle":
const newValue = !state.active;
return {
...state,
active: newValue,
contentStatus: newValue ? "Deactivate" : "Activate",
textStatus: newValue ? "activated" : "deactivated"
};
default:
throw new Error();
}
}
const initialState = {
active: false,
contentStatus: "Activate",
textStatus: "deactivated",
};
const ToggleWrapper = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<SettingToggle
action={{
content: state.contentStatus,
onAction: () => dispatch({ type: "toggle" })
}}
enabled={state.active}
>
This setting is <TextStyle variation="strong">{state.textStatus}</TextStyle>.
</SettingToggle>
)
}
export default function SettingToggleExample() {
return (
<>
<ToggleWrapper />
<ToggleWrapper />
</>
);
}

setting ref on functional component

I am trying to change this class based react component to a functional component but i am gettig an infinite loop issue on setting the reference, i think its because of on each render the ref is a new object.
How could i convert the class based component to a functional component
index-class.js - Ref
class Collapse extends React.Component {
constructor(props) {
super(props);
this.state = {
showContent: false,
height: "0px",
myRef: null,
};
}
componentDidUpdate = (prevProps, prevState) => {
if (prevState.height === "auto" && this.state.height !== "auto") {
setTimeout(() => this.setState({ height: "0px" }), 1);
}
}
setInnerRef = (ref) => this.setState({ myRef: ref });
toggleOpenClose = () => this.setState({
showContent: !this.state.showContent,
height: this.state.myRef.scrollHeight,
});
updateAfterTransition = () => {
if (this.state.showContent) {
this.setState({ height: "auto" });
}
};
render() {
const { title, children } = this.props;
return (
<div>
<h2 onClick={() => this.toggleOpenClose()}>
Example
</h2>
<div
ref={this.setInnerRef}
onTransitionEnd={() => this.updateAfterTransition()}
style={{
height: this.state.height,
overflow: "hidden",
transition: "height 250ms linear 0s",
}}
>
{children}
</div>
</div>
);
}
}
what i have tried so far.
index-functional.js
import React, { useEffect, useState } from "react";
import { usePrevious } from "./usePrevious";
const Collapse = (props) => {
const { title, children } = props || {};
const [state, setState] = useState({
showContent: false,
height: "0px",
myRef: null
});
const previousHeight = usePrevious(state.height);
useEffect(() => {
if (previousHeight === "auto" && state.height !== "auto") {
setTimeout(
() => setState((prevState) => ({ ...prevState, height: "0px" })),
1
);
}
}, [previousHeight, state.height]);
const setInnerRef = (ref) =>
setState((prevState) => ({ ...prevState, myRef: ref }));
const toggleOpenClose = () =>
setState((prevState) => ({
...prevState,
showContent: !state.showContent,
height: state.myRef.scrollHeight
}));
const updateAfterTransition = () => {
if (state.showContent) {
this.setState((prevState) => ({ ...prevState, height: "auto" }));
}
};
return (
<div>
<h2 onClick={toggleOpenClose}>{title}</h2>
<div
ref={setInnerRef}
onTransitionEnd={updateAfterTransition}
style={{
height: state.height,
overflow: "hidden",
transition: "height 250ms linear 0s"
}}
>
{children}
</div>
</div>
);
};
usePrevious.js - Link
import { useRef, useEffect } from "react";
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
export { usePrevious };
The problem here is you set your reference to update through setState and useEffect (which is what causes you the infinite loop).
The way you would go by setting references on functional components would be as followed:
const Component = () => {
const ref = useRef(null)
return (
<div ref={ref} />
)
}
More info can be found here: https://reactjs.org/docs/refs-and-the-dom.html

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>

Resources