Skip to content

Commit

Permalink
feat(ui-hooks): update useSafeMemo and useUpdatedRef (#2309)
Browse files Browse the repository at this point in the history
also update 'react-hooks/exhaustive-deps' ESLint rule
  • Loading branch information
skamril authored Jan 23, 2025
1 parent e48d550 commit bbff912
Show file tree
Hide file tree
Showing 14 changed files with 106 additions and 82 deletions.
8 changes: 8 additions & 0 deletions webapp/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ export default [
"react/hook-use-state": "error",
"react/prop-types": "off",
"react/self-closing-comp": "error",
"react-hooks/exhaustive-deps": [
"warn",
{
// Includes hooks from 'react-use'
additionalHooks:
"(useSafeMemo|useUpdateEffectOnce|useDeepCompareEffect|useShallowCompareEffect|useCustomCompareEffect)",
},
],
"require-await": "warn", // TODO: switch to "error" when the quantity of warning will be low
},
},
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/components/App/Singlestudy/FreezeStudy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { Backdrop, Button, List, ListItem, ListItemText, Paper, Typography } fro
import { useEffect, useState } from "react";
import LinearProgressWithLabel from "@/components/common/LinearProgressWithLabel";
import { useTranslation } from "react-i18next";
import useAutoUpdateRef from "@/hooks/useAutoUpdateRef";
import useUpdatedRef from "@/hooks/useUpdatedRef";

interface BlockingTask {
id: TaskDTO["id"];
Expand Down Expand Up @@ -60,7 +60,7 @@ function FreezeStudy({ studyId }: FreezeStudyProps) {
const [blockingTasks, setBlockingTasks] = useState<BlockingTask[]>([]);
const { t } = useTranslation();
const hasLoadingTask = !!blockingTasks.find(isLoadingTask);
const blockingTasksRef = useAutoUpdateRef(blockingTasks);
const blockingTasksRef = useUpdatedRef(blockingTasks);

// Fetch blocking tasks and subscribe to their WebSocket channels
useEffect(() => {
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/components/common/Form/useFormApiPlus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {
} from "react-hook-form";
import * as RA from "ramda-adjunct";
import { useEffect, useMemo, useRef } from "react";
import useAutoUpdateRef from "../../../hooks/useAutoUpdateRef";
import useUpdatedRef from "../../../hooks/useUpdatedRef";
import type {
UseFormRegisterPlus,
UseFormReturnPlus,
Expand Down Expand Up @@ -57,7 +57,7 @@ function useFormApiPlus<TFieldValues extends FieldValues, TContext>(
const initialDefaultValues = useRef(isLoading ? undefined : getDefaultValues());

// Prevent to add the values in `useMemo`'s deps
const dataRef = useAutoUpdateRef({
const dataRef = useUpdatedRef({
...data,
// Don't read `formState` in `useMemo`. See `useEffect`'s comment below.
isSubmitting,
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/components/common/Form/useFormUndoRedo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type { FieldValues } from "react-hook-form";
import { useCallback, useEffect, useRef } from "react";
import * as R from "ramda";
import type { UseFormReturnPlus } from "./types";
import useAutoUpdateRef from "../../../hooks/useAutoUpdateRef";
import useUpdatedRef from "../../../hooks/useUpdatedRef";

enum ActionType {
Undo = "UNDO",
Expand All @@ -34,7 +34,7 @@ function useFormUndoRedo<TFieldValues extends FieldValues, TContext>(
} = api;
const [state, { undo, redo, set, ...rest }] = useUndo(initialDefaultValues);
const lastAction = useRef<ActionType | "">("");
const dataRef = useAutoUpdateRef({ state, initialDefaultValues });
const dataRef = useUpdatedRef({ state, initialDefaultValues });

useEffect(
() => {
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/components/common/GroupedDataTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import ConfirmationDialog from "../dialogs/ConfirmationDialog";
import { generateUniqueValue, getTableOptionsForAlign } from "./utils";
import DuplicateDialog from "./DuplicateDialog";
import { translateWithColon } from "../../../utils/i18nUtils";
import useAutoUpdateRef from "../../../hooks/useAutoUpdateRef";
import useUpdatedRef from "../../../hooks/useUpdatedRef";
import * as R from "ramda";
import * as RA from "ramda-adjunct";
import type { PromiseAny } from "../../../utils/tsUtils";
Expand Down Expand Up @@ -85,7 +85,7 @@ function GroupedDataTable<TGroups extends string[], TData extends TRow<TGroups[n
const [rowSelection, setRowSelection] = useState<MRT_RowSelectionState>({});
const enqueueErrorSnackbar = useEnqueueErrorSnackbar();
// Allow to use the last version of `onNameClick` in `tableColumns`
const callbacksRef = useAutoUpdateRef({ onNameClick });
const callbacksRef = useUpdatedRef({ onNameClick });
const pendingRows = useRef<Array<TRow<TGroups[number]>>>([]);
const { createOps, deleteOps, totalOps } = useOperationInProgressCount();

Expand Down
6 changes: 3 additions & 3 deletions webapp/src/components/common/JSONEditor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { useDeepCompareEffect, useMount } from "react-use";
import "jsoneditor/dist/jsoneditor.min.css";
import "./dark-theme.css";
import type { PromiseAny } from "../../../utils/tsUtils";
import useAutoUpdateRef from "../../../hooks/useAutoUpdateRef";
import useUpdatedRef from "../../../hooks/useUpdatedRef";
import { createSaveButton } from "./utils";
import * as R from "ramda";
import * as RA from "ramda-adjunct";
Expand All @@ -37,8 +37,8 @@ function JSONEditor(props: JSONEditorProps) {
const { json, onSave, onSaveSuccessful, ...options } = props;
const ref = useRef<HTMLDivElement | null>(null);
const editorRef = useRef<JSONEditorClass>();
const onSaveRef = useAutoUpdateRef(onSave);
const callbackOptionsRef = useAutoUpdateRef<Partial<JSONEditorOptions>>(
const onSaveRef = useUpdatedRef(onSave);
const callbackOptionsRef = useUpdatedRef<Partial<JSONEditorOptions>>(
R.pickBy(RA.isFunction, options),
);
const saveBtn = useMemo(() => createSaveButton(handleSaveClick), []);
Expand Down
14 changes: 8 additions & 6 deletions webapp/src/components/common/TableForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import Form, { type FormProps } from "../Form";
import Table, { type TableProps } from "./Table";
import { getCellType } from "./utils";
import { mergeSxProp } from "../../../utils/muiUtils";
import useMemoLocked from "../../../hooks/useMemoLocked";
import useSafeMemo from "../../../hooks/useSafeMemo";

type TableFieldValuesByRow = Record<IdType, Record<string, string | boolean | number>>;

Expand Down Expand Up @@ -61,11 +61,13 @@ function TableForm<TFieldValues extends TableFieldValuesByRow>(
const { columns, type, colHeaders, ...restTableProps } = tableProps;

// useForm's defaultValues are cached on the first render within the custom hook.
const defaultData = useMemoLocked(() =>
R.keys(defaultValues).map((id) => ({
...defaultValues[id],
id: id as IdType,
})),
const defaultData = useSafeMemo(
() =>
R.keys(defaultValues).map((id) => ({
...defaultValues[id],
id: id as IdType,
})),
[],
);

const formattedColumns = useMemo(() => {
Expand Down
27 changes: 0 additions & 27 deletions webapp/src/hooks/useAutoUpdateRef.ts

This file was deleted.

4 changes: 2 additions & 2 deletions webapp/src/hooks/useBlocker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import type { History, Transition } from "history";
import { useContext, useEffect } from "react";
import { UNSAFE_NavigationContext as NavigationContext } from "react-router-dom";
import useAutoUpdateRef from "./useAutoUpdateRef";
import useUpdatedRef from "./useUpdatedRef";

// * Workaround until it will be supported by react-router v6.
// * Based on https://ui.dev/react-router-preventing-transitions
Expand All @@ -24,7 +24,7 @@ type Blocker = (tx: Transition) => void;

function useBlocker(blocker: Blocker, when = true): void {
const { navigator } = useContext(NavigationContext);
const blockerRef = useAutoUpdateRef(blocker);
const blockerRef = useUpdatedRef(blocker);

useEffect(
() => {
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/hooks/useConfirm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*/

import { useCallback, useRef, useState } from "react";
import useAutoUpdateRef from "./useAutoUpdateRef";
import useUpdatedRef from "./useUpdatedRef";

function errorFunction() {
throw new Error("Promise is not pending.");
Expand Down Expand Up @@ -61,7 +61,7 @@ function errorFunction() {
*/
function useConfirm() {
const [isPending, setIsPending] = useState(false);
const isPendingRef = useAutoUpdateRef(isPending);
const isPendingRef = useUpdatedRef(isPending);
const yesRef = useRef<VoidFunction>(errorFunction);
const noRef = useRef<VoidFunction>(errorFunction);
const cancelRef = useRef<VoidFunction>(errorFunction);
Expand Down
32 changes: 0 additions & 32 deletions webapp/src/hooks/useMemoLocked.ts

This file was deleted.

4 changes: 2 additions & 2 deletions webapp/src/hooks/usePromiseHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { type SnackbarKey, useSnackbar } from "notistack";
import useEnqueueErrorSnackbar from "./useEnqueueErrorSnackbar";
import { toError } from "../utils/fnUtils";
import { useCallback } from "react";
import useAutoUpdateRef from "./useAutoUpdateRef";
import useUpdatedRef from "./useUpdatedRef";

interface UsePromiseHandlerParams<T extends unknown[], U> {
fn: (...args: T) => Promise<U>;
Expand All @@ -34,7 +34,7 @@ interface UsePromiseHandlerParams<T extends unknown[], U> {
function usePromiseHandler<T extends unknown[], U>(params: UsePromiseHandlerParams<T, U>) {
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const enqueueErrorSnackbar = useEnqueueErrorSnackbar();
const paramsRef = useAutoUpdateRef(params);
const paramsRef = useUpdatedRef(params);

const handlePromise = useCallback(
async (...args: T) => {
Expand Down
38 changes: 38 additions & 0 deletions webapp/src/hooks/useSafeMemo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025, RTE (https://www.rte-france.com)
*
* See AUTHORS.txt
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* SPDX-License-Identifier: MPL-2.0
*
* This file is part of the Antares project.
*/

import { useState } from "react";
import { useUpdateEffect } from "react-use";

/**
* Hook that returns a memoized value with semantic guarantee.
*
* Semantic guarantee is not provided by `useMemo`, which is solely used
* for performance optimization (cf. https://react.dev/reference/react/useMemo#caveats).
*
* @param factory - A function that returns the value to memoize.
* @param deps - Dependencies that trigger the memoization.
* @returns The memoized value.
*/
function useSafeMemo<T>(factory: () => T, deps: React.DependencyList): T {
const [state, setState] = useState(factory);

useUpdateEffect(() => {
setState(factory);
}, deps);

return state;
}

export default useSafeMemo;
35 changes: 35 additions & 0 deletions webapp/src/hooks/useUpdatedRef.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2025, RTE (https://www.rte-france.com)
*
* See AUTHORS.txt
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* SPDX-License-Identifier: MPL-2.0
*
* This file is part of the Antares project.
*/

import { useLayoutEffect, useRef } from "react";

/**
* Hook that returns a mutable ref that automatically updates its value.
*
* @param value - The value to store in the ref.
* @returns The mutable ref.
*/
function useUpdatedRef<T>(value: T): React.MutableRefObject<T> {
const ref = useRef(value);

// `useLayoutEffect` runs before `useEffect`. So `useLayoutEffect` is used to make sure
// the value is up-to-date before any other code runs.
useLayoutEffect(() => {
ref.current = value;
});

return ref;
}

export default useUpdatedRef;

0 comments on commit bbff912

Please sign in to comment.