How to open multiple mui DialogProvider dynamically? - reactjs

I have a generic DialogProvider that written with MUI and I open all dialog modals with this generic provider.
Code is below.
import React from "react";
import { makeAutoObservable } from "mobx";
import {
BooleanValuePaths,
getValue,
setValue,
Paths,
PathValueType,
StringValuePaths,
} from "../utils/utils";
import { IAppState, useAppState } from "./app-state";
type BaseType = object;
export class DialogState<T extends BaseType> {
_data: T;
_resetFunction?: () => T;
_touched: BooleanValuePaths<Paths<T>>;
_error: StringValuePaths<Paths<T>>;
_formError?: string;
_initialData: T;
constructor(dataInitiator: () => T) {
this._data = dataInitiator();
this._initialData = dataInitiator();
this._resetFunction = dataInitiator;
this._touched = {};
this._error = {};
makeAutoObservable(this);
}
reset() {
this._resetFunction?.();
}
get data() {
return this._data;
}
get formTouched() {
const touched = Object.keys(this._touched).find(
(key) => this._touched[key as Paths<T>],
);
return touched;
}
setValue<P extends Paths<T>>(path: P, value: PathValueType<T, P>): void {
setValue(this._data, path, value as any); //TODO remove the "as any", but the typescript has problem
this._touched[path] = true;
}
getValue<P extends Paths<T>>(path: P): PathValueType<T, P> {
return getValue(this._data, path) as PathValueType<T, P>; //TODO remove the "as PathValueType<T, P>", but the typescript has problem
}
isTouched<P extends Paths<T>>(path: P) {
return this._touched[path];
}
errorOf<P extends Paths<T>>(path: P) {
return this._error[path];
}
setErrorOf<P extends Paths<T> | "">(path: P, error: string) {
this._error[path] = error;
}
resetErrors() {
this._error = {};
}
resetTouches() {
this._touched = {};
}
resetData() {
this._data = this._initialData;
}
setFormError(error: string) {
this._formError = error;
}
}
function processErrorFunction<T extends BaseType>(
state: DialogState<T>,
err: any,
appState: IAppState,
errorKeyMap?: { errorKey: string; dataKey: string }[],
) {
try {
const json = JSON.parse(err?.message);
if (Array.isArray(json?.message)) {
(json.message as any[]).forEach((m) => {
if (
typeof m?.property === "string" &&
typeof m?.constraints === "object"
) {
const msg = Object.keys(m.constraints)
.map((k) => m.constraints[k])
.join(", ");
const dataKey =
errorKeyMap?.find((erk) => erk.errorKey === m.property)?.dataKey ??
m.property;
state.setErrorOf(dataKey, msg);
}
});
} else {
appState.setSnackMessage({ message: json?.message || "unknown" });
}
} catch (_) {
appState.setSnackMessage({ message: err?.message || "unknown" });
}
}
export function useDialogStateProvider<T extends BaseType>(
stateInitiator: () => T,
saverFunction: (v: T, appState: IAppState) => Promise<any>,
closeFunction?: (value?: T) => void,
errorKeyMap?: { errorKey: string; dataKey: string }[],
) {
const appState = useAppState();
const [loading, setLoading] = React.useState(false);
const state = React.useMemo(
() => new DialogState(stateInitiator),
[stateInitiator],
);
const reset = () => {
state.reset();
};
const handleSave = async () => {
try {
setLoading(true);
state.resetErrors();
await saverFunction(state.data, appState);
state.resetTouches();
if (closeFunction) {
state.resetData();
closeFunction(state.data);
reset();
}
} catch (err: any) {
processErrorFunction(state, err, appState, errorKeyMap);
} finally {
setLoading(false);
}
};
const handleClose = () => {
try {
if (closeFunction) {
setLoading(false);
state.resetErrors();
state.resetData();
state.resetTouches();
closeFunction(state.data);
reset();
}
} catch (err: any) {
processErrorFunction(state, err, appState, errorKeyMap);
} finally {
setLoading(false);
}
};
return {
reset,
state,
loading,
handleSave,
handleClose,
};
}
interface useDialogStateProviderInterface<T extends BaseType> {
(stateProvider: () => T): {
reset: () => void;
state: DialogState<T>;
loading: boolean;
handleSave: () => Promise<void>;
handleClose?: () => void;
};
}
export type DialogStateProviderType<T extends BaseType> = ReturnType<
useDialogStateProviderInterface<T>
>;
export const DialogStateContext =
React.createContext<DialogStateProviderType<any> | null>(null);
export function DialogStateProvider<
K extends BaseType,
T extends DialogStateProviderType<K>,
>({ state, children }: { state: T; children?: React.ReactNode }) {
return (
<DialogStateContext.Provider value={state}>
{children}
</DialogStateContext.Provider>
);
}
export function useDialogState<T extends BaseType>() {
const store = React.useContext(DialogStateContext);
if (!store) {
throw new Error("useDialogState must be used within DialogStateProvider");
}
return store as DialogStateProviderType<T>;
}
Usage is like below:
<DialogStateProvider state={taskState}>
<BaseDialog open={open} handleClose={handleClose} title={title}>
<AssignedTaskEditor isSubsequent={isSubsequent} />
</BaseDialog>
</DialogStateProvider>
BaseDialog code is this:
import {
Dialog,
DialogContent,
DialogTitle,
IconButton,
SxProps,
Theme,
useMediaQuery,
useTheme,
} from "#mui/material";
import CloseIcon from "#mui/icons-material/Close";
export function BaseDialog({
open,
handleClose,
title,
children,
sx,
}: {
open: boolean;
handleClose: () => void;
title?: React.ReactNode;
children: React.ReactNode;
sx?: SxProps<Theme>;
}) {
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("md"));
return (
<Dialog
fullScreen={fullScreen}
open={open}
onClose={handleClose}
maxWidth="lg"
sx={{
...sx,
"& .MuiDialog-container > .MuiPaper-root": {
height: "100%",
backgroundColor: "background.default",
...(sx as any)?.["& .MuiDialog-container > .MuiPaper-root"],
},
"& .MuiDialogContent-root": {
flexGrow: 1,
backgroundColor: "background.default",
maxHeight: "80vh",
marginBlock: 2,
...(sx as any)?.["& .MuiDialogContent-root"],
},
"& .dialogBox-Icon": {
position: "absolute",
right: (theme) => theme.spacing(2.5),
top: (theme) => theme.spacing(1),
...(sx as any)?.["& .dialogBox-Icon"],
},
}}
>
{title && <DialogTitle>{title}</DialogTitle>}
<IconButton onClick={handleClose} className="dialogBox-Icon" size="large">
<CloseIcon />
</IconButton>
<DialogContent dividers>{children}</DialogContent>
</Dialog>
);
}
I want to open multiple dialog concurrently like that:
Save and add a subsequent task button should call an API endpoint to save data and it should close current modal and open another dialog and this should go forever. There should not be an end.
I have searched on internet but I couldn't find good example for this.
How can I do that with this current generic DialogProvider?

Related

How to cluster polygons with react-leaflet?

I'm looking for a way to cluster polygons using react-leaflet v4 and react-leaflet-markercluster. I have not found any up-to-date examples of how I can achieve this, so I'm hoping I might get some help here.
Any example code to get me started would be a great help!
This will probably not solve your problem directly but hopefully show that using markercluster is rather simple. The only thing you need is to have a createMarkerCluster function.
clusterProps has a field for polygonOptions:
/*
* Options to pass when creating the L.Polygon(points, options) to show the bounds of a cluster.
* Defaults to empty
*/
polygonOptions?: PolylineOptions | undefined;
Since you now use a plain leaflet plugin it opens up for mor information on the internet, these two might help how you should configure polygonOptions
How to make MarkerClusterGroup cluster polygons
https://gis.stackexchange.com/questions/197882/is-it-possible-to-cluster-polygons-in-leaflet
Below is my general code to make clustermarkers work with React:
import { createPathComponent } from "#react-leaflet/core";
import L, { LeafletMouseEventHandlerFn } from "leaflet";
import "leaflet.markercluster";
import { ReactElement, useMemo } from "react";
import { Building, BuildingStore, Circle } from "tabler-icons-react";
import { createLeafletIcon } from "./utils";
import styles from "./LeafletMarkerCluster.module.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
type ClusterType = { [key in string]: any };
type ClusterEvents = {
onClick?: LeafletMouseEventHandlerFn;
onDblClick?: LeafletMouseEventHandlerFn;
onMouseDown?: LeafletMouseEventHandlerFn;
onMouseUp?: LeafletMouseEventHandlerFn;
onMouseOver?: LeafletMouseEventHandlerFn;
onMouseOut?: LeafletMouseEventHandlerFn;
onContextMenu?: LeafletMouseEventHandlerFn;
};
// Leaflet is badly typed, if more props needed add them to the interface.
// Look in this file to see what is available.
// node_modules/#types/leaflet.markercluster/index.d.ts
// MarkerClusterGroupOptions
export interface LeafletMarkerClusterProps {
spiderfyOnMaxZoom?: boolean;
children: React.ReactNode;
size?: number;
icon?: ReactElement;
}
const createMarkerCluster = (
{
children: _c,
size = 30,
icon = <Circle size={size} />,
...props
}: LeafletMarkerClusterProps,
context: any
) => {
const markerIcons = {
default: <Circle size={size} />,
property: <Building size={size} />,
business: <BuildingStore size={size} />,
} as { [key in string]: ReactElement };
const clusterProps: ClusterType = {
iconCreateFunction: (cluster: any) => {
const markers = cluster.getAllChildMarkers();
const types = markers.reduce(
(
acc: { [x: string]: number },
marker: {
key: string;
options: { icon: { options: { className: string } } };
}
) => {
const key = marker?.key || "";
const type =
marker.options.icon.options.className || key.split("-")[0];
const increment = (key.split("-")[1] as unknown as number) || 1;
if (type in markerIcons) {
return { ...acc, [type]: (acc[type] || 0) + increment };
}
return { ...acc, default: (acc.default || 0) + increment };
},
{}
) as { [key in string]: number };
const typeIcons = Object.entries(types).map(([type, count], index) => {
if (count > 0) {
const typeIcon = markerIcons[type];
return (
<div key={`${type}-${count}`} style={{ display: "flex" }}>
<span>{typeIcon}</span>
<span style={{ width: "max-content" }}>{count}</span>
</div>
);
}
});
const iconWidth = typeIcons.length * size;
return createLeafletIcon(
<div style={{ display: "flex" }} className={"cluster-marker"}>
{typeIcons}
</div>,
iconWidth,
undefined,
iconWidth,
30
);
},
showCoverageOnHover: false,
animate: true,
animateAddingMarkers: false,
removeOutsideVisibleBounds: false,
};
const clusterEvents: ClusterType = {};
// Splitting props and events to different objects
Object.entries(props).forEach(([propName, prop]) =>
propName.startsWith("on")
? (clusterEvents[propName] = prop)
: (clusterProps[propName] = prop)
);
const instance = new (L as any).MarkerClusterGroup(clusterProps);
instance.on("spiderfied", (e: any) => {
e.cluster._icon?.classList.add(styles.spiderfied);
});
instance.on("unspiderfied", (e: any) => {
e.cluster._icon?.classList.remove(styles.spiderfied);
});
// This is not used at the moment, but could be used to add events to the cluster.
// Initializing event listeners
Object.entries(clusterEvents).forEach(([eventAsProp, callback]) => {
const clusterEvent = `cluster${eventAsProp.substring(2).toLowerCase()}`;
instance.on(clusterEvent, callback);
});
return {
instance,
context: {
...context,
layerContainer: instance,
},
};
};
const updateMarkerCluster = (instance: any, props: any, prevProps: any) => {};
const LeafletMarkerCluster = createPathComponent(
createMarkerCluster,
updateMarkerCluster
);
const LeafletMarkerClusterWrapper: React.FC<LeafletMarkerClusterProps> = ({
children,
...props
}) => {
const markerCluster = useMemo(() => {
return <LeafletMarkerCluster>{children}</LeafletMarkerCluster>;
}, [children]);
return <>{markerCluster}</>;
};
export default LeafletMarkerClusterWrapper;
Below is my function to create a marker icon from react elements:
import { divIcon } from "leaflet";
import { ReactElement } from "react";
import { renderToString } from "react-dom/server";
export const createLeafletIcon = (
icon: ReactElement,
size: number,
className?: string,
width: number = size,
height: number = size
) => {
return divIcon({
html: renderToString(icon),
iconSize: [width, height],
iconAnchor: [width / 2, height],
popupAnchor: [0, -height],
className: className ? className : "",
});
};

React issues with reading a value in a context

I have this context:
FilterProvider.tsx
import React, { createContext, useRef } from "react";
interface IFiltersContext {
filters: { [key: string]: any };
updateFilter: (name: string, value: any) => void;
addFilter: (name: string, value: any) => void;
clearFilter: (name: string) => void;
}
type FilterContextProps = {
initialFilters?: any;
onFilterChange: (values: any) => void;
};
export const FiltersContext = createContext<IFiltersContext>({
filters: {},
updateFilter: () => {},
addFilter: () => {},
clearFilter: () => {}
});
export const FiltersProvider: React.FC<FilterContextProps> = ({
children,
onFilterChange,
initialFilters = {}
}) => {
const filters = useRef(initialFilters);
const updateFilter = (name: string, value: any) => {
addFilter(name, value);
onFilterChange(filters.current);
};
const addFilter = (name: string, value: any) => {
filters.current = {
...filters.current,
[name]: value
};
};
const clearFilter = (name: string) => {
if (filters.current[name] !== null && filters.current[name] !== undefined) {
updateFilter(name, null);
}
};
return (
<FiltersContext.Provider
value={{ filters, updateFilter, addFilter, clearFilter }}
>
{children}
</FiltersContext.Provider>
);
};
And to be able to use this functions I use it as follows
<FiltersProvider onFilterChange={function (values: any): void {
console.log("Function not implemented.");
} }>
<PrettyTable
tableName="table_publications"
overlayFilter={
<>
<CountryFilter />
</>
}
{...tableData}
/>
</FiltersProvider>
Now inside PrettyTable I have the following : (NOTE1****)
const { showDialog } = useDialog();
const { filters, addFilter } = useContext(FiltersContext);
console.log(filters?.current) //always "somename", "test" , only the function call in Confirm will save things. THe one inside COuntryFIlter (See below) wont
function showFilterSelector() {
showDialog({
title: "Filter Columns",
message: <DialogContent />,
cancel: {
action: () => console.log("cancelled"),
message: "Reset Filters"
},
confirm: {
action: () => addFilter("somename", "test"), //I can see this calling addFilter, and after that "filters.current" has its value.
message: "Apply Filters"
},
align: "start",
width: "100%",
height: "100%",
wide: true
});
}
useEffect(() => {
debugger;
},[filters])
const DialogContent = () => {
return (
<Grid
columns={["flex", "flex", "flex"]}
justifyContent={"around"}
gap="small"
>
{props.overlayFilter} --> Prop found in the first code snippet, content of that component is in the last code snippet (below)
</Grid>
);
};
In the code above, im able to see the confirm action calling the function inside the provider, and it works just fine
But props.overlayFIlter contain the following:
Which is also using the same context inside
export const CountryFilter = ({...}) => {
const { filters, addFilter, updateFilter, clearFilter } = useContext(FiltersContext);
return (
<SelectComponent
onChange={i => {
addFilter("filter1","x") //This one is also being called supposedly in the context, but later I cant read it anywhere
}} /> )
But the above function despite calling the same context, the added data wont be able to be read in (NOTE1*** code snippet). I will only see the filter registered in the confirm action ("somename", "test")
What am I dont wrong? Am I using the contexts wrong?

pass coordinates from googlemaps react child to parent in functional component typescript

I'm using #googlemaps/react-wrapper to make a map component in my react application using the example from googlemaps, and adding an event on drag marker to refresh coordinates, this works fine now. but i need to call the map component outside to refresh a input value with the coordiantes.
The error i get it is:
Binding element 'childToParent' implicitly has an 'any' type.*
Please could help me to understand how i could send the values to paren using typescript
Greetings
In parent i have this
const [coordinate,SetCoordinate]=useState("");
return (
<FormProvider methods={methods} onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={3}>
<RHFTextField name="lat" label="Coord X" />
<RHFTextField name="lng" label="Coord Y" />
</Stack>
<Stack>
<br/>
<LocationMap childToParent={setCoordinate}/>
</Stack>
<Stack>
<LoadingButton
fullWidth
size="large"
type="submit"
variant="contained"
>
Save
</LoadingButton>
</Stack>
</FormProvider>
);
My Location map component is like this
const render = (status: Status) => {
return <h1>{status}</h1>;
};
interface MapProps extends google.maps.MapOptions {
style: { [key: string]: string };
onClick?: (e: google.maps.MapMouseEvent) => void;
onIdle?: (map: google.maps.Map) => void;
}
//function to pass value to parent
interface LocationProps {
childToParent: (arg0: string)=>string;
}
export default function LocationMap({childToParent,...props}){
const [clicks, setClicks] = useState<google.maps.LatLng[]>([]);
const [zoom, setZoom] = useState(3); // initial zoom
const [center, setCenter] = useState<google.maps.LatLngLiteral>({
lat: 0.0,
lng: 0.0,
});
const [markerLocation, setMarkerLocation] = useState<google.maps.LatLng>();
const dragend = (e: google.maps.MapMouseEvent) => {
// avoid directly mutating state
setMarkerLocation(e.latLng!)
setClicks([...clicks, e.latLng!]);
};
const onClick = (e: google.maps.MapMouseEvent) => {
};
const onIdle = (m: google.maps.Map) => {
//.log("onIdle");
setZoom(m.getZoom()!);
setCenter(m.getCenter()!.toJSON());
};
const ref = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<google.maps.Map>();
useEffect(() => {
if (ref.current && !map) {
setMap(new window.google.maps.Map(ref.current, {}));
}
}, [ref, map]);
return (
<>
<div style={{ display: "flex", height: "100%" }}>
<Wrapper apiKey={apiKey} render={render}>
<Map
center={center}
onClick={onClick}
onIdle={onIdle}
zoom={zoom}
style={{ flexGrow: "1", height: "25em", width: "400px" }}
>
<Marker key="point" draggable={true} dragend={dragend} />
</Map>
</Wrapper>
</div>
<div id="coordinate">
{clicks.map(function (latLng, i, row) {
var element = document.getElementById("coordenadas");
if (element === null) {
console.error("error cleaning coordinates");
} else {
element.innerHTML = "";
}
return (
childToParent(latLng.toJSON())
);
})
}
</div>
</>
)
};
interface MapProps extends google.maps.MapOptions {
onClick?: (e: google.maps.MapMouseEvent) => void;
onIdle?: (map: google.maps.Map) => void;
}
const Map: React.FC<MapProps> = ({
onClick,
onIdle,
children,
style,
...options
}) => {
const ref = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<google.maps.Map>();
useEffect(() => {
if (ref.current && !map) {
setMap(new window.google.maps.Map(ref.current, {}));
}
}, [ref, map]);
// because React does not do deep comparisons, a custom hook is used
// see discussion in https://github.com/googlemaps/js-samples/issues/946
useDeepCompareEffectForMaps(() => {
if (map) {
map.setOptions(options);
}
}, [map, options]);
useEffect(() => {
if (map) {
["click", "idle"].forEach((eventName) =>
google.maps.event.clearListeners(map, eventName)
);
if (onClick) {
map.addListener("click", onClick);
}
if (onIdle) {
map.addListener("idle", () => onIdle(map));
}
}
}, [map, onClick, onIdle]);
return (
<>
<div ref={ref} style={style} />
{Children.map(children, (child) => {
if (isValidElement(child)) {
// set the map prop on the child component
return cloneElement(child, { map });
}
})}
</>
);
};
interface MarkerProps extends google.maps.MarkerOptions {
dragend?: (e: google.maps.MapMouseEvent) => void;
}
const Marker: React.FC<MarkerProps> = ({
dragend,
...options
}) => {
const [marker, setMarker] = useState<google.maps.Marker>();
console.log(options);
useEffect(() => {
if (!marker) {
setMarker(new google.maps.Marker({
position: {
lat: 0,
lng: 0,
},
}));
}
// remove marker from map on unmount
return () => {
if (marker) {
marker.setMap(null);
}
};
}, [marker]);
useEffect(() => {
if (marker) {
marker.setOptions(options);
}
}, [marker, options]);
useEffect(() => {
if (marker) {
["dragend"].forEach((eventName) =>
google.maps.event.clearListeners(marker, eventName)
);
marker.setOptions(options);
if (dragend) {
//map.addListener("click", onClick);
marker.addListener("dragend", dragend);
}
}
}, [marker, dragend, options]);
return null;
};
const deepCompareEqualsForMaps = createCustomEqual(
(deepEqual) => (a: any, b: any) => {
if (
isLatLngLiteral(a) ||
a instanceof google.maps.LatLng ||
isLatLngLiteral(b) ||
b instanceof google.maps.LatLng
) {
return new google.maps.LatLng(a).equals(new google.maps.LatLng(b));
}
// TODO extend to other types
// use fast-equals for other objects
return deepEqual(a, b);
}
);
function useDeepCompareMemoize(value: any) {
const ref = useRef();
if (!deepCompareEqualsForMaps(value, ref.current)) {
ref.current = value;
}
return ref.current;
}
function useDeepCompareEffectForMaps(
callback: React.EffectCallback,
dependencies: any[]
) {
useEffect(callback, dependencies.map(useDeepCompareMemoize));
}
export default LocationMap;
It is warning you because you have not passed the correct function. This should fix the problem:
const [coordinate, setCoordinate] = useState("");
const deepCompareEqualsForMaps = createCustomEqual((deepEqual: any, ref: any) => (a: any, b: any) => {
if (
isLatLngLiteral(a) ||
a instanceof window.google.maps.LatLng ||
isLatLngLiteral(b) ||
b instanceof window.google.maps.LatLng
) {
return new window.google.maps.LatLng(a).equals(new window.google.maps.LatLng(b));
}
// TODO extend to other types
// use fast-equals for other objects
return deepEqual(a, b);
}
);

Using DraftJS in a Functional Component

I am trying to implement DraftJS within an existing functional component and I am unable to figure out how to do this. It appears that all of the documentation and user-submitted content refers to class components.
I try to set it up using the following:
import { Editor } from "react-draft-wysiwyg";
import { EditorState } from 'draft-js'
export default function myFunctionalComponent() {
const [editorState, setEditorState] = useState(EditorState.createEmpty())
return(
<Editor
editorState={editorState}
onChange={setEditorState}
/>
)
}
However, unfortunately, I get this error in the console:
Warning: Can't call setState on a component that is not yet mounted.
This is a no-op, but it might indicate a bug in your application.
Instead, assign to this.state directly or define a state = {};
class property with the desired state in the r component.
Is there a way to make this work in a functional component?
As my very first answer in StackOverflow. :)
I took the example from https://github.com/facebook/draft-js/blob/main/examples/draft-0-10-0/rich/rich.html and converted it into a functional components 'RTEditor', .. .
Use the component with setContent as a prop. It takes the function to update parent elements state from useState
const [content, setContent] = useState<any>({})
...
<RTEditor setContent={setContent} />
RTEditor.tsx
import React, { useState, useRef } from 'react'
import {
Editor,
EditorState,
RichUtils,
getDefaultKeyBinding,
ContentBlock,
DraftHandleValue,
convertFromHTML,
convertFromRaw,
convertToRaw,
ContentState,
RawDraftContentState,
} from 'draft-js'
import 'draft-js/dist/Draft.css'
import BlockStyleControls from './BlockStyleControls'
import InlineStyleControls from './InlineStyleControls'
type Props = {
setContent: (state: RawDraftContentState) => void
}
const RTEditor = ({ setContent }: Props) => {
const editorRef = useRef(null)
const [editorState, setEditorState] = useState(EditorState.createEmpty())
const styleMap = {
CODE: {
backgroundColor: 'rgba(0, 0, 0, 0.05)',
fontFamily: '"Inconsolata", "Menlo", "Consolas", monospace',
fontSize: 16,
padding: 2,
},
}
const getBlockStyle = (block: ContentBlock) => {
switch (block.getType()) {
case 'blockquote':
return 'RichEditor-blockquote'
default:
return ''
}
}
const onChange = (state: EditorState) => {
setEditorState(state)
setContent(convertToRaw(editorState.getCurrentContent()))
}
const mapKeyToEditorCommand = (e: any): string | null => {
if (e.keyCode === 9 /* TAB */) {
const newEditorState = RichUtils.onTab(e, editorState, 4 /* maxDepth */)
if (newEditorState !== editorState) {
onChange(newEditorState)
}
return null
}
return getDefaultKeyBinding(e)
}
const handleKeyCommand = (
command: string,
editorState: EditorState,
eventTimeStamp: number
): DraftHandleValue => {
const newState = RichUtils.handleKeyCommand(editorState, command)
if (newState) {
onChange(newState)
return 'handled'
}
return 'not-handled'
}
const toggleBlockType = (blockType: string) => {
onChange(RichUtils.toggleBlockType(editorState, blockType))
}
const toggleInlineStyle = (inlineStyle: string) => {
onChange(RichUtils.toggleInlineStyle(editorState, inlineStyle))
}
return (
<>
<BlockStyleControls
editorState={editorState}
onToggle={toggleBlockType}
/>
<InlineStyleControls
editorState={editorState}
onToggle={toggleInlineStyle}
/>
<Editor
ref={editorRef}
editorState={editorState}
placeholder='Tell a story...'
customStyleMap={styleMap}
blockStyleFn={(block: ContentBlock) => getBlockStyle(block)}
keyBindingFn={(e) => mapKeyToEditorCommand(e)}
onChange={onChange}
spellCheck={true}
handleKeyCommand={handleKeyCommand}
/>
</>
)
}
export default React.memo(RTEditor)
BlockStyleControls.tsx
import React from 'react'
import { EditorState } from 'draft-js'
import StyleButton from './StyleButton'
const BLOCK_TYPES = [
{ label: 'H1', style: 'header-one' },
{ label: 'H2', style: 'header-two' },
{ label: 'H3', style: 'header-three' },
{ label: 'H4', style: 'header-four' },
{ label: 'H5', style: 'header-five' },
{ label: 'H6', style: 'header-six' },
{ label: 'Blockquote', style: 'blockquote' },
{ label: 'UL', style: 'unordered-list-item' },
{ label: 'OL', style: 'ordered-list-item' },
{ label: 'Code Block', style: 'code-block' },
]
type Props = {
editorState: EditorState
onToggle: (bockType: string) => void
}
const BlockStyleControls = ({ editorState, onToggle }: Props) => {
const selection = editorState.getSelection()
const blockType = editorState
.getCurrentContent()
.getBlockForKey(selection.getStartKey())
.getType()
return (
<div className='RichEditor-controls'>
{BLOCK_TYPES.map((type) => (
<StyleButton
key={type.label}
active={type.style === blockType}
label={type.label}
onToggle={onToggle}
style={type.style}
/>
))}
</div>
)
}
export default React.memo(BlockStyleControls)
InlineStyleControls.tsx
import React from 'react'
import { EditorState } from 'draft-js'
import StyleButton from './StyleButton'
const INLINE_STYLES = [
{ label: 'Bold', style: 'BOLD' },
{ label: 'Italic', style: 'ITALIC' },
{ label: 'Underline', style: 'UNDERLINE' },
{ label: 'Monospace', style: 'CODE' },
]
type Props = {
editorState: EditorState
onToggle: (bockType: string) => void
}
const InlineStyleControls = ({ editorState, onToggle }: Props) => {
const currentStyle = editorState.getCurrentInlineStyle()
return (
<div className='RichEditor-controls'>
{INLINE_STYLES.map((type) => (
<StyleButton
key={type.label}
active={currentStyle.has(type.style)}
label={type.label}
onToggle={onToggle}
style={type.style}
/>
))}
</div>
)
}
export default React.memo(InlineStyleControls)
StyleButton.tsx
import React from 'react'
type Props = {
active: boolean
style: string
label: string
onToggle: (bockType: string) => void
}
const StyleButton = ({ active, style, label, onToggle }: Props) => {
const _onToggle = (e: any) => {
e.preventDefault()
onToggle(style)
}
const className = 'RichEditor-styleButton'
return (
<button
className={className + `${active ? ' RichEditor-activeButton' : ''}`}
onClick={_onToggle}
>
{label}
</button>
)
}
export default React.memo(StyleButton)
Sry for not covering all typings. Hope that helps.
I was able to solve this using React useCallback hook and it works for me.
import { EditorState } from 'draft-js'
export default function myFunctionalComponent() {
const [editorState, setEditorState] = useState(EditorState.createEmpty())
const onEditorStateChange = useCallback(
(rawcontent) => {
setEditorState(rawcontent.blocks[0].text);
},
[editorState]
);
return(
<Editor
placeholder="Tell a story..."
onChange={onEditorStateChange}
/>
)
}

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