diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json
index 0205b8a0fd..f3d8e241a9 100644
--- a/webapp/public/locales/en/main.json
+++ b/webapp/public/locales/en/main.json
@@ -70,6 +70,7 @@
   "global.assign": "Assign",
   "global.undo": "Undo",
   "global.redo": "Redo",
+  "global.total": "Total",
   "global.time.hourly": "Hourly",
   "global.time.daily": "Daily",
   "global.time.weekly": "Weekly",
@@ -81,6 +82,8 @@
   "global.error.failedtoretrievejobs": "Failed to retrieve job information",
   "global.error.failedtoretrievelogs": "Failed to retrieve job logs",
   "global.error.failedtoretrievedownloads": "Failed to retrieve downloads list",
+  "global.error.create": "Creation failed",
+  "global.error.delete": "Deletion failed",
   "global.area.add": "Add an area",
   "login.error": "Failed to authenticate",
   "tasks.title": "Tasks",
@@ -94,6 +97,7 @@
   "data.title": "Data",
   "dialog.title.confirmation": "Confirmation",
   "dialog.message.logout": "Are you sure you want to logout?",
+  "dialog.message.confirmDelete": "Do you confirm the deletion?",
   "button.collapse": "Collapse",
   "button.expand": "Expand",
   "button.yes": "Yes",
@@ -117,7 +121,7 @@
   "form.submit.inProgress": "The form is being submitted. Are you sure you want to leave the page?",
   "form.asyncDefaultValues.error": "Failed to get values",
   "form.field.required": "Field required",
-  "form.field.duplicate": "Value already exists: {{0}}",
+  "form.field.duplicate": "Value already exists",
   "form.field.minLength": "{{0}} character(s) minimum",
   "form.field.minValue": "The minimum value is {{0}}",
   "form.field.maxValue": "The maximum value is {{0}}",
@@ -486,8 +490,8 @@
   "study.modelization.clusters.matrix.timeSeries": "Time-Series",
   "study.modelization.clusters.backClusterList": "Back to cluster list",
   "study.modelization.clusters.tsInterpretation": "TS interpretation",
-  "study.modelization.clusters.group": "Group",
-  "studies.modelization.clusters.question.delete": "Are you sure you want to delete this cluster?",
+  "studies.modelization.clusters.question.delete_one": "Are you sure you want to delete this cluster?",
+  "studies.modelization.clusters.question.delete_other": "Are you sure you want to delete these {{count}} clusters?",
   "study.modelization.bindingConst.comments": "Comments",
   "study.modelization.bindingConst.type": "Type",
   "study.modelization.bindingConst.constraints": "Constraints",
diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json
index 86768663c2..bec0e911cc 100644
--- a/webapp/public/locales/fr/main.json
+++ b/webapp/public/locales/fr/main.json
@@ -70,6 +70,7 @@
   "global.assign": "Assigner",
   "global.undo": "Annuler",
   "global.redo": "Rétablir",
+  "global.total": "Total",
   "global.time.hourly": "Horaire",
   "global.time.daily": "Journalier",
   "global.time.weekly": "Hebdomadaire",
@@ -81,6 +82,8 @@
   "global.error.failedtoretrievejobs": "Échec de la récupération des tâches",
   "global.error.failedtoretrievelogs": "Échec de la récupération des logs",
   "global.error.failedtoretrievedownloads": "Échec de la récupération des exports",
+  "global.error.create": "La création a échoué",
+  "global.error.delete": "La suppression a échoué",
   "global.area.add": "Ajouter une zone",
   "login.error": "Échec de l'authentification",
   "tasks.title": "Tâches",
@@ -94,6 +97,7 @@
   "data.title": "Données",
   "dialog.title.confirmation": "Confirmation",
   "dialog.message.logout": "Êtes vous sûr de vouloir vous déconnecter ?",
+  "dialog.message.confirmDelete": "Confirmez-vous la suppression ?",
   "button.collapse": "Réduire",
   "button.expand": "Étendre",
   "button.yes": "Oui",
@@ -117,7 +121,7 @@
   "form.submit.inProgress": "Le formulaire est en cours de soumission. Etes-vous sûr de vouloir quitter la page ?",
   "form.asyncDefaultValues.error": "Impossible d'obtenir les valeurs",
   "form.field.required": "Champ requis",
-  "form.field.duplicate": "Cette valeur existe déjà: {{0}}",
+  "form.field.duplicate": "Cette valeur existe déjà",
   "form.field.minLength": "{{0}} caractère(s) minimum",
   "form.field.minValue": "La valeur minimum est {{0}}",
   "form.field.maxValue": "La valeur maximum est {{0}}",
@@ -486,8 +490,8 @@
   "study.modelization.clusters.matrix.timeSeries": "Séries temporelles",
   "study.modelization.clusters.backClusterList": "Retour à la liste des clusters",
   "study.modelization.clusters.tsInterpretation": "TS interpretation",
-  "study.modelization.clusters.group": "Groupes",
-  "studies.modelization.clusters.question.delete": "Êtes-vous sûr de vouloir supprimer ce cluster ?",
+  "studies.modelization.clusters.question.delete_one": "Êtes-vous sûr de vouloir supprimer ce cluster ?",
+  "studies.modelization.clusters.question.delete_other": "Êtes-vous sûr de vouloir supprimer ces {{count}} clusters ?",
   "study.modelization.bindingConst.comments": "Commentaires",
   "study.modelization.bindingConst.type": "Type",
   "study.modelization.bindingConst.constraints": "Contraintes",
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Fields.tsx
index 16e45f7c53..7b7ea9774c 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Fields.tsx
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/Fields.tsx
@@ -29,7 +29,7 @@ function Fields() {
           disabled
         />
         <SelectFE
-          label={t("study.modelization.clusters.group")}
+          label={t("global.group")}
           name="group"
           control={control}
           options={RENEWABLE_GROUPS}
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx
index c66c2b388a..d6f987753a 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx
@@ -1,144 +1,100 @@
-import { useMemo } from "react";
-import { MRT_ColumnDef } from "material-react-table";
-import { Box, Chip } from "@mui/material";
+import { useMemo, useState } from "react";
+import { createMRTColumnHelper } from "material-react-table";
+import { Box } from "@mui/material";
 import { useLocation, useNavigate, useOutletContext } from "react-router-dom";
 import { useTranslation } from "react-i18next";
 import { StudyMetadata } from "../../../../../../../common/types";
 import {
   RENEWABLE_GROUPS,
-  RenewableCluster,
-  RenewableClusterWithCapacity,
+  RenewableGroup,
   createRenewableCluster,
   deleteRenewableClusters,
+  duplicateRenewableCluster,
   getRenewableClusters,
+  type RenewableClusterWithCapacity,
 } from "./utils";
 import useAppSelector from "../../../../../../../redux/hooks/useAppSelector";
 import { getCurrentAreaId } from "../../../../../../../redux/selectors";
 import GroupedDataTable from "../../../../../../common/GroupedDataTable";
 import {
+  addClusterCapacity,
   capacityAggregationFn,
-  useClusterDataWithCapacity,
-} from "../common/utils";
-import SimpleLoader from "../../../../../../common/loaders/SimpleLoader";
-import SimpleContent from "../../../../../../common/page/SimpleContent";
-import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond";
+  getClustersWithCapacityTotals,
+} from "../common/clustersUtils";
+import { TRow } from "../../../../../../common/GroupedDataTable/types";
+import BooleanCell from "../../../../../../common/GroupedDataTable/cellRenderers/BooleanCell";
+import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError";
+
+const columnHelper = createMRTColumnHelper<RenewableClusterWithCapacity>();
 
 function Renewables() {
   const { study } = useOutletContext<{ study: StudyMetadata }>();
-  const [t] = useTranslation();
-  const areaId = useAppSelector(getCurrentAreaId);
+  const { t } = useTranslation();
   const navigate = useNavigate();
   const location = useLocation();
+  const areaId = useAppSelector(getCurrentAreaId);
 
-  const {
-    clusters,
-    clustersWithCapacity,
-    totalUnitCount,
-    totalInstalledCapacity,
-    totalEnabledCapacity,
-  } = useClusterDataWithCapacity<RenewableCluster>(
-    () => getRenewableClusters(study.id, areaId),
-    t("studies.error.retrieveData"),
-    [study.id, areaId],
-  );
-
-  const columns = useMemo<Array<MRT_ColumnDef<RenewableClusterWithCapacity>>>(
-    () => [
-      {
-        accessorKey: "name",
-        header: "Name",
-        muiTableHeadCellProps: {
-          align: "left",
-        },
-        muiTableBodyCellProps: {
-          align: "left",
-        },
-        size: 100,
-        Cell: ({ renderedCellValue, row }) => {
-          const clusterId = row.original.id;
-          return (
-            <Box
-              sx={{
-                cursor: "pointer",
-                "&:hover": {
-                  color: "primary.main",
-                  textDecoration: "underline",
-                },
-              }}
-              onClick={() => navigate(`${location.pathname}/${clusterId}`)}
-            >
-              {renderedCellValue}
-            </Box>
-          );
-        },
+  const { data: clustersWithCapacity = [], isLoading } =
+    usePromiseWithSnackbarError<RenewableClusterWithCapacity[]>(
+      async () => {
+        const clusters = await getRenewableClusters(study.id, areaId);
+        return clusters?.map(addClusterCapacity);
       },
       {
-        accessorKey: "group",
-        header: "Group",
-        size: 50,
-        filterVariant: "select",
-        filterSelectOptions: [...RENEWABLE_GROUPS],
-        muiTableHeadCellProps: {
-          align: "left",
-        },
-        muiTableBodyCellProps: {
-          align: "left",
-        },
-        Footer: () => (
-          <Box sx={{ display: "flex", alignItems: "flex-start" }}>Total:</Box>
-        ),
+        resetDataOnReload: true,
+        errorMessage: t("studies.error.retrieveData"),
+        deps: [study.id, areaId],
       },
-      {
-        accessorKey: "enabled",
+    );
+
+  const [totals, setTotals] = useState(
+    getClustersWithCapacityTotals(clustersWithCapacity),
+  );
+
+  const columns = useMemo(() => {
+    const { totalUnitCount, totalEnabledCapacity, totalInstalledCapacity } =
+      totals;
+
+    return [
+      columnHelper.accessor("enabled", {
         header: "Enabled",
         size: 50,
         filterVariant: "checkbox",
-        Cell: ({ cell }) => (
-          <Chip
-            label={cell.getValue<boolean>() ? t("button.yes") : t("button.no")}
-            color={cell.getValue<boolean>() ? "success" : "error"}
-            size="small"
-            sx={{ minWidth: 40 }}
-          />
-        ),
-      },
-      {
-        accessorKey: "tsInterpretation",
+        Cell: BooleanCell,
+      }),
+      columnHelper.accessor("tsInterpretation", {
         header: "TS Interpretation",
         size: 50,
-      },
-      {
-        accessorKey: "unitCount",
+      }),
+      columnHelper.accessor("unitCount", {
         header: "Unit Count",
         size: 50,
         aggregationFn: "sum",
         AggregatedCell: ({ cell }) => (
           <Box sx={{ color: "info.main", fontWeight: "bold" }}>
-            {cell.getValue<number>()}
+            {cell.getValue()}
           </Box>
         ),
         Footer: () => <Box color="warning.main">{totalUnitCount}</Box>,
-      },
-      {
-        accessorKey: "nominalCapacity",
+      }),
+      columnHelper.accessor("nominalCapacity", {
         header: "Nominal Capacity (MW)",
-        size: 200,
-        Cell: ({ cell }) => Math.floor(cell.getValue<number>()),
-      },
-      {
-        accessorKey: "installedCapacity",
+        size: 220,
+        Cell: ({ cell }) => Math.floor(cell.getValue()),
+      }),
+      columnHelper.accessor("installedCapacity", {
         header: "Enabled / Installed (MW)",
-        size: 200,
+        size: 220,
         aggregationFn: capacityAggregationFn(),
         AggregatedCell: ({ cell }) => (
           <Box sx={{ color: "info.main", fontWeight: "bold" }}>
-            {cell.getValue<string>() ?? ""}
+            {cell.getValue() ?? ""}
           </Box>
         ),
         Cell: ({ row }) => (
           <>
-            {Math.floor(row.original.enabledCapacity ?? 0)} /{" "}
-            {Math.floor(row.original.installedCapacity ?? 0)}
+            {Math.floor(row.original.enabledCapacity)} /{" "}
+            {Math.floor(row.original.installedCapacity)}
           </>
         ),
         Footer: () => (
@@ -146,53 +102,68 @@ function Renewables() {
             {totalEnabledCapacity} / {totalInstalledCapacity}
           </Box>
         ),
-      },
-    ],
-    [
-      location.pathname,
-      navigate,
-      t,
-      totalEnabledCapacity,
-      totalInstalledCapacity,
-      totalUnitCount,
-    ],
-  );
+      }),
+    ];
+  }, [totals]);
 
   ////////////////////////////////////////////////////////////////
   // Event handlers
   ////////////////////////////////////////////////////////////////
 
-  const handleCreateRow = ({
-    id,
-    installedCapacity,
-    enabledCapacity,
-    ...cluster
-  }: RenewableClusterWithCapacity) => {
-    return createRenewableCluster(study.id, areaId, cluster);
+  const handleCreate = async (values: TRow<RenewableGroup>) => {
+    const cluster = await createRenewableCluster(study.id, areaId, values);
+    return addClusterCapacity(cluster);
   };
 
-  const handleDeleteSelection = (ids: string[]) => {
+  const handleDuplicate = async (
+    row: RenewableClusterWithCapacity,
+    newName: string,
+  ) => {
+    const cluster = await duplicateRenewableCluster(
+      study.id,
+      areaId,
+      row.id,
+      newName,
+    );
+
+    return { ...row, ...cluster };
+  };
+
+  const handleDelete = (rows: RenewableClusterWithCapacity[]) => {
+    const ids = rows.map((row) => row.id);
     return deleteRenewableClusters(study.id, areaId, ids);
   };
 
+  const handleNameClick = (row: RenewableClusterWithCapacity) => {
+    navigate(`${location.pathname}/${row.id}`);
+  };
+
   ////////////////////////////////////////////////////////////////
   // JSX
   ////////////////////////////////////////////////////////////////
 
   return (
-    <UsePromiseCond
-      response={clusters}
-      ifPending={() => <SimpleLoader />}
-      ifResolved={() => (
-        <GroupedDataTable
-          data={clustersWithCapacity}
-          columns={columns}
-          groups={RENEWABLE_GROUPS}
-          onCreate={handleCreateRow}
-          onDelete={handleDeleteSelection}
-        />
-      )}
-      ifRejected={(error) => <SimpleContent title={error?.toString()} />}
+    <GroupedDataTable
+      isLoading={isLoading}
+      data={clustersWithCapacity}
+      columns={columns}
+      groups={[...RENEWABLE_GROUPS]}
+      onCreate={handleCreate}
+      onDuplicate={handleDuplicate}
+      onDelete={handleDelete}
+      onNameClick={handleNameClick}
+      deleteConfirmationMessage={(count) =>
+        t("studies.modelization.clusters.question.delete", { count })
+      }
+      fillPendingRow={(row) => ({
+        unitCount: 0,
+        enabledCapacity: 0,
+        installedCapacity: 0,
+        ...row,
+      })}
+      onDataChange={(data) => {
+        setTotals(getClustersWithCapacityTotals(data));
+      }}
     />
   );
 }
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts
index 074a19c84f..0c0418d8d3 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts
@@ -4,6 +4,8 @@ import {
   StudyMetadata,
 } from "../../../../../../../common/types";
 import client from "../../../../../../../services/api/client";
+import type { PartialExceptFor } from "../../../../../../../utils/tsUtils";
+import type { ClusterWithCapacity } from "../common/clustersUtils";
 
 ////////////////////////////////////////////////////////////////
 // Constants
@@ -30,8 +32,9 @@ export const TS_INTERPRETATION_OPTIONS = [
 // Types
 ////////////////////////////////////////////////////////////////
 
+export type RenewableGroup = (typeof RENEWABLE_GROUPS)[number];
+
 type TimeSeriesInterpretation = (typeof TS_INTERPRETATION_OPTIONS)[number];
-type RenewableGroup = (typeof RENEWABLE_GROUPS)[number];
 
 export interface RenewableFormFields {
   name: string;
@@ -52,10 +55,8 @@ export interface RenewableCluster {
   nominalCapacity: number;
 }
 
-export interface RenewableClusterWithCapacity extends RenewableCluster {
-  installedCapacity: number;
-  enabledCapacity: number;
-}
+export type RenewableClusterWithCapacity =
+  ClusterWithCapacity<RenewableCluster>;
 
 ////////////////////////////////////////////////////////////////
 // Functions
@@ -72,34 +73,29 @@ const getClusterUrl = (
   clusterId: Cluster["id"],
 ): string => `${getClustersUrl(studyId, areaId)}/${clusterId}`;
 
-async function makeRequest<T>(
-  method: "get" | "post" | "patch" | "delete",
-  url: string,
-  data?: Partial<RenewableCluster> | { data: Array<Cluster["id"]> },
-): Promise<T> {
-  const res = await client[method]<T>(url, data);
-  return res.data;
-}
+////////////////////////////////////////////////////////////////
+// API
+////////////////////////////////////////////////////////////////
 
 export async function getRenewableClusters(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
-): Promise<RenewableCluster[]> {
-  return makeRequest<RenewableCluster[]>(
-    "get",
+) {
+  const res = await client.get<RenewableCluster[]>(
     getClustersUrl(studyId, areaId),
   );
+  return res.data;
 }
 
 export async function getRenewableCluster(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
   clusterId: Cluster["id"],
-): Promise<RenewableCluster> {
-  return makeRequest<RenewableCluster>(
-    "get",
+) {
+  const res = await client.get<RenewableCluster>(
     getClusterUrl(studyId, areaId, clusterId),
   );
+  return res.data;
 }
 
 export async function updateRenewableCluster(
@@ -107,32 +103,44 @@ export async function updateRenewableCluster(
   areaId: Area["name"],
   clusterId: Cluster["id"],
   data: Partial<RenewableCluster>,
-): Promise<RenewableCluster> {
-  return makeRequest<RenewableCluster>(
-    "patch",
+) {
+  const res = await client.patch<RenewableCluster>(
     getClusterUrl(studyId, areaId, clusterId),
     data,
   );
+  return res.data;
 }
 
 export async function createRenewableCluster(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
-  data: Partial<RenewableCluster>,
-): Promise<RenewableClusterWithCapacity> {
-  return makeRequest<RenewableClusterWithCapacity>(
-    "post",
+  data: PartialExceptFor<RenewableCluster, "name">,
+) {
+  const res = await client.post<RenewableCluster>(
     getClustersUrl(studyId, areaId),
     data,
   );
+  return res.data;
+}
+
+export async function duplicateRenewableCluster(
+  studyId: StudyMetadata["id"],
+  areaId: Area["name"],
+  sourceClusterId: RenewableCluster["id"],
+  newName: RenewableCluster["name"],
+) {
+  const res = await client.post<RenewableCluster>(
+    `/v1/studies/${studyId}/areas/${areaId}/renewables/${sourceClusterId}`,
+    null,
+    { params: { newName } },
+  );
+  return res.data;
 }
 
-export function deleteRenewableClusters(
+export async function deleteRenewableClusters(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
   clusterIds: Array<Cluster["id"]>,
-): Promise<void> {
-  return makeRequest<void>("delete", getClustersUrl(studyId, areaId), {
-    data: clusterIds,
-  });
+) {
+  await client.delete(getClustersUrl(studyId, areaId), { data: clusterIds });
 }
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx
index 8485fd29e6..9d6935b3fe 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/Fields.tsx
@@ -25,7 +25,7 @@ function Fields() {
         disabled
       />
       <SelectFE
-        label={t("study.modelization.clusters.group")}
+        label={t("global.group")}
         name="group"
         control={control}
         options={STORAGE_GROUPS}
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx
index 06e6db36db..721409091a 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/index.tsx
@@ -1,109 +1,52 @@
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
 import { useTranslation } from "react-i18next";
-import { MRT_ColumnDef } from "material-react-table";
-import { Box, Chip, Tooltip } from "@mui/material";
+import { createMRTColumnHelper } from "material-react-table";
+import { Box, Tooltip } from "@mui/material";
 import { useLocation, useNavigate, useOutletContext } from "react-router-dom";
 import { StudyMetadata } from "../../../../../../../common/types";
 import useAppSelector from "../../../../../../../redux/hooks/useAppSelector";
 import { getCurrentAreaId } from "../../../../../../../redux/selectors";
 import GroupedDataTable from "../../../../../../common/GroupedDataTable";
-import SimpleLoader from "../../../../../../common/loaders/SimpleLoader";
 import {
   Storage,
   getStorages,
   deleteStorages,
   createStorage,
   STORAGE_GROUPS,
+  StorageGroup,
+  duplicateStorage,
+  getStoragesTotals,
 } from "./utils";
-import SimpleContent from "../../../../../../common/page/SimpleContent";
-import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond";
 import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError";
+import type { TRow } from "../../../../../../common/GroupedDataTable/types";
+import BooleanCell from "../../../../../../common/GroupedDataTable/cellRenderers/BooleanCell";
+
+const columnHelper = createMRTColumnHelper<Storage>();
 
 function Storages() {
   const { study } = useOutletContext<{ study: StudyMetadata }>();
-  const [t] = useTranslation();
+  const { t } = useTranslation();
   const navigate = useNavigate();
   const location = useLocation();
   const areaId = useAppSelector(getCurrentAreaId);
 
-  const storages = usePromiseWithSnackbarError(
+  const { data: storages = [], isLoading } = usePromiseWithSnackbarError(
     () => getStorages(study.id, areaId),
     {
+      resetDataOnReload: true,
       errorMessage: t("studies.error.retrieveData"),
       deps: [study.id, areaId],
     },
   );
 
-  const { totalWithdrawalNominalCapacity, totalInjectionNominalCapacity } =
-    useMemo(() => {
-      if (!storages.data) {
-        return {
-          totalWithdrawalNominalCapacity: 0,
-          totalInjectionNominalCapacity: 0,
-        };
-      }
+  const [totals, setTotals] = useState(getStoragesTotals(storages));
 
-      return storages.data.reduce(
-        (acc, { withdrawalNominalCapacity, injectionNominalCapacity }) => {
-          acc.totalWithdrawalNominalCapacity += withdrawalNominalCapacity;
-          acc.totalInjectionNominalCapacity += injectionNominalCapacity;
-          return acc;
-        },
-        {
-          totalWithdrawalNominalCapacity: 0,
-          totalInjectionNominalCapacity: 0,
-        },
-      );
-    }, [storages]);
+  const columns = useMemo(() => {
+    const { totalInjectionNominalCapacity, totalWithdrawalNominalCapacity } =
+      totals;
 
-  const columns = useMemo<Array<MRT_ColumnDef<Storage>>>(
-    () => [
-      {
-        accessorKey: "name",
-        header: t("global.name"),
-        muiTableHeadCellProps: {
-          align: "left",
-        },
-        muiTableBodyCellProps: {
-          align: "left",
-        },
-        size: 100,
-        Cell: ({ renderedCellValue, row }) => {
-          const storageId = row.original.id;
-          return (
-            <Box
-              sx={{
-                cursor: "pointer",
-                "&:hover": {
-                  color: "primary.main",
-                  textDecoration: "underline",
-                },
-              }}
-              onClick={() => navigate(`${location.pathname}/${storageId}`)}
-            >
-              {renderedCellValue}
-            </Box>
-          );
-        },
-      },
-      {
-        accessorKey: "group",
-        header: t("global.group"),
-        size: 50,
-        filterVariant: "select",
-        filterSelectOptions: [...STORAGE_GROUPS],
-        muiTableHeadCellProps: {
-          align: "left",
-        },
-        muiTableBodyCellProps: {
-          align: "left",
-        },
-        Footer: () => (
-          <Box sx={{ display: "flex", alignItems: "flex-start" }}>Total:</Box>
-        ),
-      },
-      {
-        accessorKey: "injectionNominalCapacity",
+    return [
+      columnHelper.accessor("injectionNominalCapacity", {
         header: t("study.modelization.storages.injectionNominalCapacity"),
         Header: ({ column }) => (
           <Tooltip
@@ -117,20 +60,20 @@ function Storages() {
           </Tooltip>
         ),
         size: 100,
-        Cell: ({ cell }) => Math.floor(cell.getValue<number>()),
+        aggregationFn: "sum",
         AggregatedCell: ({ cell }) => (
           <Box sx={{ color: "info.main", fontWeight: "bold" }}>
-            {Math.floor(cell.getValue<number>())}
+            {Math.floor(cell.getValue())}
           </Box>
         ),
+        Cell: ({ cell }) => Math.floor(cell.getValue()),
         Footer: () => (
           <Box color="warning.main">
             {Math.floor(totalInjectionNominalCapacity)}
           </Box>
         ),
-      },
-      {
-        accessorKey: "withdrawalNominalCapacity",
+      }),
+      columnHelper.accessor("withdrawalNominalCapacity", {
         header: t("study.modelization.storages.withdrawalNominalCapacity"),
         Header: ({ column }) => (
           <Tooltip
@@ -147,18 +90,17 @@ function Storages() {
         aggregationFn: "sum",
         AggregatedCell: ({ cell }) => (
           <Box sx={{ color: "info.main", fontWeight: "bold" }}>
-            {Math.floor(cell.getValue<number>())}
+            {Math.floor(cell.getValue())}
           </Box>
         ),
-        Cell: ({ cell }) => Math.floor(cell.getValue<number>()),
+        Cell: ({ cell }) => Math.floor(cell.getValue()),
         Footer: () => (
           <Box color="warning.main">
             {Math.floor(totalWithdrawalNominalCapacity)}
           </Box>
         ),
-      },
-      {
-        accessorKey: "reservoirCapacity",
+      }),
+      columnHelper.accessor("reservoirCapacity", {
         header: t("study.modelization.storages.reservoirCapacity"),
         Header: ({ column }) => (
           <Tooltip
@@ -170,74 +112,73 @@ function Storages() {
           </Tooltip>
         ),
         size: 100,
-        Cell: ({ cell }) => `${cell.getValue<number>()}`,
-      },
-      {
-        accessorKey: "efficiency",
+        Cell: ({ cell }) => `${cell.getValue()}`,
+      }),
+      columnHelper.accessor("efficiency", {
         header: t("study.modelization.storages.efficiency"),
         size: 50,
-        Cell: ({ cell }) => `${Math.floor(cell.getValue<number>() * 100)}`,
-      },
-      {
-        accessorKey: "initialLevel",
+        Cell: ({ cell }) => `${Math.floor(cell.getValue() * 100)}`,
+      }),
+      columnHelper.accessor("initialLevel", {
         header: t("study.modelization.storages.initialLevel"),
         size: 50,
-        Cell: ({ cell }) => `${Math.floor(cell.getValue<number>() * 100)}`,
-      },
-      {
-        accessorKey: "initialLevelOptim",
+        Cell: ({ cell }) => `${Math.floor(cell.getValue() * 100)}`,
+      }),
+      columnHelper.accessor("initialLevelOptim", {
         header: t("study.modelization.storages.initialLevelOptim"),
-        size: 180,
+        size: 200,
         filterVariant: "checkbox",
-        Cell: ({ cell }) => (
-          <Chip
-            label={cell.getValue<boolean>() ? t("button.yes") : t("button.no")}
-            color={cell.getValue<boolean>() ? "success" : "error"}
-            size="small"
-            sx={{ minWidth: 40 }}
-          />
-        ),
-      },
-    ],
-    [
-      location.pathname,
-      navigate,
-      t,
-      totalInjectionNominalCapacity,
-      totalWithdrawalNominalCapacity,
-    ],
-  );
+        Cell: BooleanCell,
+      }),
+    ];
+  }, [t, totals]);
 
   ////////////////////////////////////////////////////////////////
   // Event handlers
   ////////////////////////////////////////////////////////////////
 
-  const handleCreateRow = ({ id, ...storage }: Storage) => {
-    return createStorage(study.id, areaId, storage);
+  const handleCreate = (values: TRow<StorageGroup>) => {
+    return createStorage(study.id, areaId, values);
+  };
+
+  const handleDuplicate = (row: Storage, newName: string) => {
+    return duplicateStorage(study.id, areaId, row.id, newName);
   };
 
-  const handleDeleteSelection = (ids: string[]) => {
+  const handleDelete = (rows: Storage[]) => {
+    const ids = rows.map((row) => row.id);
     return deleteStorages(study.id, areaId, ids);
   };
 
+  const handleNameClick = (row: Storage) => {
+    navigate(`${location.pathname}/${row.id}`);
+  };
+
   ////////////////////////////////////////////////////////////////
   // JSX
   ////////////////////////////////////////////////////////////////
 
   return (
-    <UsePromiseCond
-      response={storages}
-      ifPending={() => <SimpleLoader />}
-      ifResolved={(data) => (
-        <GroupedDataTable
-          data={data}
-          columns={columns}
-          groups={STORAGE_GROUPS}
-          onCreate={handleCreateRow}
-          onDelete={handleDeleteSelection}
-        />
-      )}
-      ifRejected={(error) => <SimpleContent title={error?.toString()} />}
+    <GroupedDataTable
+      isLoading={isLoading}
+      data={storages || []}
+      columns={columns}
+      groups={[...STORAGE_GROUPS]}
+      onCreate={handleCreate}
+      onDuplicate={handleDuplicate}
+      onDelete={handleDelete}
+      onNameClick={handleNameClick}
+      deleteConfirmationMessage={(count) =>
+        t("studies.modelization.clusters.question.delete", { count })
+      }
+      fillPendingRow={(row) => ({
+        withdrawalNominalCapacity: 0,
+        injectionNominalCapacity: 0,
+        ...row,
+      })}
+      onDataChange={(data) => {
+        setTotals(getStoragesTotals(data));
+      }}
     />
   );
 }
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts
index 1226bcac66..48466da646 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Storages/utils.ts
@@ -1,5 +1,6 @@
 import { StudyMetadata, Area } from "../../../../../../../common/types";
 import client from "../../../../../../../services/api/client";
+import type { PartialExceptFor } from "../../../../../../../utils/tsUtils";
 
 ////////////////////////////////////////////////////////////////
 // Constants
@@ -39,6 +40,20 @@ export interface Storage {
 // Functions
 ////////////////////////////////////////////////////////////////
 
+export function getStoragesTotals(storages: Storage[]) {
+  return storages.reduce(
+    (acc, { withdrawalNominalCapacity, injectionNominalCapacity }) => {
+      acc.totalWithdrawalNominalCapacity += withdrawalNominalCapacity;
+      acc.totalInjectionNominalCapacity += injectionNominalCapacity;
+      return acc;
+    },
+    {
+      totalWithdrawalNominalCapacity: 0,
+      totalInjectionNominalCapacity: 0,
+    },
+  );
+}
+
 const getStoragesUrl = (
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
@@ -50,28 +65,27 @@ const getStorageUrl = (
   storageId: Storage["id"],
 ): string => `${getStoragesUrl(studyId, areaId)}/${storageId}`;
 
-async function makeRequest<T>(
-  method: "get" | "post" | "patch" | "delete",
-  url: string,
-  data?: Partial<Storage> | { data: Array<Storage["id"]> },
-): Promise<T> {
-  const res = await client[method]<T>(url, data);
-  return res.data;
-}
+////////////////////////////////////////////////////////////////
+// API
+////////////////////////////////////////////////////////////////
 
 export async function getStorages(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
-): Promise<Storage[]> {
-  return makeRequest<Storage[]>("get", getStoragesUrl(studyId, areaId));
+) {
+  const res = await client.get<Storage[]>(getStoragesUrl(studyId, areaId));
+  return res.data;
 }
 
 export async function getStorage(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
   storageId: Storage["id"],
-): Promise<Storage> {
-  return makeRequest<Storage>("get", getStorageUrl(studyId, areaId, storageId));
+) {
+  const res = await client.get<Storage>(
+    getStorageUrl(studyId, areaId, storageId),
+  );
+  return res.data;
 }
 
 export async function updateStorage(
@@ -79,28 +93,41 @@ export async function updateStorage(
   areaId: Area["name"],
   storageId: Storage["id"],
   data: Partial<Storage>,
-): Promise<Storage> {
-  return makeRequest<Storage>(
-    "patch",
+) {
+  const res = await client.patch<Storage>(
     getStorageUrl(studyId, areaId, storageId),
     data,
   );
+  return res.data;
 }
 
 export async function createStorage(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
-  data: Partial<Storage>,
-): Promise<Storage> {
-  return makeRequest<Storage>("post", getStoragesUrl(studyId, areaId), data);
+  data: PartialExceptFor<Storage, "name">,
+) {
+  const res = await client.post<Storage>(getStoragesUrl(studyId, areaId), data);
+  return res.data;
+}
+
+export async function duplicateStorage(
+  studyId: StudyMetadata["id"],
+  areaId: Area["name"],
+  sourceClusterId: Storage["id"],
+  newName: Storage["name"],
+) {
+  const res = await client.post<Storage>(
+    `/v1/studies/${studyId}/areas/${areaId}/storages/${sourceClusterId}`,
+    null,
+    { params: { newName } },
+  );
+  return res.data;
 }
 
-export function deleteStorages(
+export async function deleteStorages(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
   storageIds: Array<Storage["id"]>,
-): Promise<void> {
-  return makeRequest<void>("delete", getStoragesUrl(studyId, areaId), {
-    data: storageIds,
-  });
+) {
+  await client.delete(getStoragesUrl(studyId, areaId), { data: storageIds });
 }
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Fields.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Fields.tsx
index cf5cb2fc66..ec5d6fc632 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Fields.tsx
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/Fields.tsx
@@ -35,7 +35,7 @@ function Fields() {
           disabled
         />
         <SelectFE
-          label={t("study.modelization.clusters.group")}
+          label={t("global.group")}
           name="group"
           control={control}
           options={THERMAL_GROUPS}
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx
index e403b52e5f..0b36a6903a 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx
@@ -1,6 +1,6 @@
-import { useMemo } from "react";
-import { MRT_ColumnDef } from "material-react-table";
-import { Box, Chip } from "@mui/material";
+import { useMemo, useState } from "react";
+import { createMRTColumnHelper } from "material-react-table";
+import { Box } from "@mui/material";
 import { useLocation, useNavigate, useOutletContext } from "react-router-dom";
 import { useTranslation } from "react-i18next";
 import { StudyMetadata } from "../../../../../../../common/types";
@@ -8,146 +8,95 @@ import {
   getThermalClusters,
   createThermalCluster,
   deleteThermalClusters,
-  ThermalClusterWithCapacity,
   THERMAL_GROUPS,
-  ThermalCluster,
+  ThermalGroup,
+  duplicateThermalCluster,
+  type ThermalClusterWithCapacity,
 } from "./utils";
 import useAppSelector from "../../../../../../../redux/hooks/useAppSelector";
 import { getCurrentAreaId } from "../../../../../../../redux/selectors";
 import GroupedDataTable from "../../../../../../common/GroupedDataTable";
-import SimpleLoader from "../../../../../../common/loaders/SimpleLoader";
-import SimpleContent from "../../../../../../common/page/SimpleContent";
 import {
+  addClusterCapacity,
   capacityAggregationFn,
-  useClusterDataWithCapacity,
-} from "../common/utils";
-import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond";
+  getClustersWithCapacityTotals,
+} from "../common/clustersUtils";
+import { TRow } from "../../../../../../common/GroupedDataTable/types";
+import BooleanCell from "../../../../../../common/GroupedDataTable/cellRenderers/BooleanCell";
+import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError";
+
+const columnHelper = createMRTColumnHelper<ThermalClusterWithCapacity>();
 
 function Thermal() {
   const { study } = useOutletContext<{ study: StudyMetadata }>();
-  const [t] = useTranslation();
+  const { t } = useTranslation();
   const navigate = useNavigate();
   const location = useLocation();
   const areaId = useAppSelector(getCurrentAreaId);
 
-  const {
-    clusters,
-    clustersWithCapacity,
-    totalUnitCount,
-    totalInstalledCapacity,
-    totalEnabledCapacity,
-  } = useClusterDataWithCapacity<ThermalCluster>(
-    () => getThermalClusters(study.id, areaId),
-    t("studies.error.retrieveData"),
-    [study.id, areaId],
-  );
-
-  const columns = useMemo<Array<MRT_ColumnDef<ThermalClusterWithCapacity>>>(
-    () => [
-      {
-        accessorKey: "name",
-        header: "Name",
-        size: 100,
-        muiTableHeadCellProps: {
-          align: "left",
-        },
-        muiTableBodyCellProps: {
-          align: "left",
-        },
-        Cell: ({ renderedCellValue, row }) => {
-          const clusterId = row.original.id;
-          return (
-            <Box
-              sx={{
-                cursor: "pointer",
-                "&:hover": {
-                  color: "primary.main",
-                  textDecoration: "underline",
-                },
-              }}
-              onClick={() => navigate(`${location.pathname}/${clusterId}`)}
-            >
-              {renderedCellValue}
-            </Box>
-          );
-        },
+  const { data: clustersWithCapacity = [], isLoading } =
+    usePromiseWithSnackbarError<ThermalClusterWithCapacity[]>(
+      async () => {
+        const clusters = await getThermalClusters(study.id, areaId);
+        return clusters?.map(addClusterCapacity);
       },
       {
-        accessorKey: "group",
-        header: "Group",
-        size: 50,
-        filterVariant: "select",
-        filterSelectOptions: [...THERMAL_GROUPS],
-        muiTableHeadCellProps: {
-          align: "left",
-        },
-        muiTableBodyCellProps: {
-          align: "left",
-        },
-        Footer: () => (
-          <Box sx={{ display: "flex", alignItems: "flex-start" }}>Total:</Box>
-        ),
+        resetDataOnReload: true,
+        errorMessage: t("studies.error.retrieveData"),
+        deps: [study.id, areaId],
       },
-      {
-        accessorKey: "enabled",
+    );
+
+  const [totals, setTotals] = useState(
+    getClustersWithCapacityTotals(clustersWithCapacity),
+  );
+
+  const columns = useMemo(() => {
+    const { totalUnitCount, totalEnabledCapacity, totalInstalledCapacity } =
+      totals;
+
+    return [
+      columnHelper.accessor("enabled", {
         header: "Enabled",
         size: 50,
         filterVariant: "checkbox",
-        Cell: ({ cell }) => (
-          <Chip
-            label={cell.getValue<boolean>() ? t("button.yes") : t("button.no")}
-            color={cell.getValue<boolean>() ? "success" : "error"}
-            size="small"
-            sx={{ minWidth: 40 }}
-          />
-        ),
-      },
-      {
-        accessorKey: "mustRun",
+        Cell: BooleanCell,
+      }),
+      columnHelper.accessor("mustRun", {
         header: "Must Run",
         size: 50,
         filterVariant: "checkbox",
-        Cell: ({ cell }) => (
-          <Chip
-            label={cell.getValue<boolean>() ? t("button.yes") : t("button.no")}
-            color={cell.getValue<boolean>() ? "success" : "error"}
-            size="small"
-            sx={{ minWidth: 40 }}
-          />
-        ),
-      },
-      {
-        accessorKey: "unitCount",
+        Cell: BooleanCell,
+      }),
+      columnHelper.accessor("unitCount", {
         header: "Unit Count",
         size: 50,
         aggregationFn: "sum",
         AggregatedCell: ({ cell }) => (
           <Box sx={{ color: "info.main", fontWeight: "bold" }}>
-            {cell.getValue<number>()}
+            {cell.getValue()}
           </Box>
         ),
         Footer: () => <Box color="warning.main">{totalUnitCount}</Box>,
-      },
-      {
-        accessorKey: "nominalCapacity",
+      }),
+      columnHelper.accessor("nominalCapacity", {
         header: "Nominal Capacity (MW)",
-        size: 200,
-        Cell: ({ cell }) => cell.getValue<number>().toFixed(1),
-      },
-      {
-        accessorKey: "installedCapacity",
+        size: 220,
+        Cell: ({ cell }) => cell.getValue().toFixed(1),
+      }),
+      columnHelper.accessor("installedCapacity", {
         header: "Enabled / Installed (MW)",
-        size: 200,
+        size: 220,
         aggregationFn: capacityAggregationFn(),
         AggregatedCell: ({ cell }) => (
           <Box sx={{ color: "info.main", fontWeight: "bold" }}>
-            {cell.getValue<string>() ?? ""}
+            {cell.getValue() ?? ""}
           </Box>
         ),
         Cell: ({ row }) => (
           <>
-            {Math.floor(row.original.enabledCapacity ?? 0)} /{" "}
-            {Math.floor(row.original.installedCapacity ?? 0)}
+            {Math.floor(row.original.enabledCapacity)} /{" "}
+            {Math.floor(row.original.installedCapacity)}
           </>
         ),
         Footer: () => (
@@ -155,59 +104,73 @@ function Thermal() {
             {totalEnabledCapacity} / {totalInstalledCapacity}
           </Box>
         ),
-      },
-      {
-        accessorKey: "marketBidCost",
+      }),
+      columnHelper.accessor("marketBidCost", {
         header: "Market Bid (€/MWh)",
         size: 50,
-        Cell: ({ cell }) => <>{cell.getValue<number>().toFixed(2)}</>,
-      },
-    ],
-    [
-      location.pathname,
-      navigate,
-      t,
-      totalEnabledCapacity,
-      totalInstalledCapacity,
-      totalUnitCount,
-    ],
-  );
+        Cell: ({ cell }) => <>{cell.getValue().toFixed(2)}</>,
+      }),
+    ];
+  }, [totals]);
 
   ////////////////////////////////////////////////////////////////
   // Event handlers
   ////////////////////////////////////////////////////////////////
 
-  const handleCreateRow = ({
-    id,
-    installedCapacity,
-    enabledCapacity,
-    ...cluster
-  }: ThermalClusterWithCapacity) => {
-    return createThermalCluster(study.id, areaId, cluster);
+  const handleCreate = async (values: TRow<ThermalGroup>) => {
+    const cluster = await createThermalCluster(study.id, areaId, values);
+    return addClusterCapacity(cluster);
   };
 
-  const handleDeleteSelection = (ids: string[]) => {
+  const handleDuplicate = async (
+    row: ThermalClusterWithCapacity,
+    newName: string,
+  ) => {
+    const cluster = await duplicateThermalCluster(
+      study.id,
+      areaId,
+      row.id,
+      newName,
+    );
+
+    return { ...row, ...cluster };
+  };
+
+  const handleDelete = (rows: ThermalClusterWithCapacity[]) => {
+    const ids = rows.map((row) => row.id);
     return deleteThermalClusters(study.id, areaId, ids);
   };
 
+  const handleNameClick = (row: ThermalClusterWithCapacity) => {
+    navigate(`${location.pathname}/${row.id}`);
+  };
+
   ////////////////////////////////////////////////////////////////
   // JSX
   ////////////////////////////////////////////////////////////////
 
   return (
-    <UsePromiseCond
-      response={clusters}
-      ifPending={() => <SimpleLoader />}
-      ifResolved={() => (
-        <GroupedDataTable
-          data={clustersWithCapacity}
-          columns={columns}
-          groups={THERMAL_GROUPS}
-          onCreate={handleCreateRow}
-          onDelete={handleDeleteSelection}
-        />
-      )}
-      ifRejected={(error) => <SimpleContent title={error?.toString()} />}
+    <GroupedDataTable
+      isLoading={isLoading}
+      data={clustersWithCapacity}
+      columns={columns}
+      groups={[...THERMAL_GROUPS]}
+      onCreate={handleCreate}
+      onDuplicate={handleDuplicate}
+      onDelete={handleDelete}
+      onNameClick={handleNameClick}
+      deleteConfirmationMessage={(count) =>
+        t("studies.modelization.clusters.question.delete", { count })
+      }
+      fillPendingRow={(row) => ({
+        unitCount: 0,
+        enabledCapacity: 0,
+        installedCapacity: 0,
+        ...row,
+      })}
+      onDataChange={(data) => {
+        setTotals(getClustersWithCapacityTotals(data));
+      }}
     />
   );
 }
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts
index d113e06c4f..8d5836a4e0 100644
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts
@@ -4,6 +4,8 @@ import {
   StudyMetadata,
 } from "../../../../../../../common/types";
 import client from "../../../../../../../services/api/client";
+import type { PartialExceptFor } from "../../../../../../../utils/tsUtils";
+import type { ClusterWithCapacity } from "../common/clustersUtils";
 
 ////////////////////////////////////////////////////////////////
 // Constants
@@ -51,7 +53,8 @@ export const TS_LAW_OPTIONS = ["geometric", "uniform"] as const;
 // Types
 ////////////////////////////////////////////////////////////////
 
-type ThermalGroup = (typeof THERMAL_GROUPS)[number];
+export type ThermalGroup = (typeof THERMAL_GROUPS)[number];
+
 type LocalTSGenerationBehavior = (typeof TS_GENERATION_OPTIONS)[number];
 type TimeSeriesLawOption = (typeof TS_LAW_OPTIONS)[number];
 
@@ -83,10 +86,7 @@ export interface ThermalCluster extends ThermalPollutants {
   lawPlanned: TimeSeriesLawOption;
 }
 
-export interface ThermalClusterWithCapacity extends ThermalCluster {
-  enabledCapacity: number;
-  installedCapacity: number;
-}
+export type ThermalClusterWithCapacity = ClusterWithCapacity<ThermalCluster>;
 
 ////////////////////////////////////////////////////////////////
 // Functions
@@ -103,31 +103,29 @@ const getClusterUrl = (
   clusterId: Cluster["id"],
 ): string => `${getClustersUrl(studyId, areaId)}/${clusterId}`;
 
-async function makeRequest<T>(
-  method: "get" | "post" | "patch" | "delete",
-  url: string,
-  data?: Partial<ThermalCluster> | { data: Array<Cluster["id"]> },
-): Promise<T> {
-  const res = await client[method]<T>(url, data);
-  return res.data;
-}
+////////////////////////////////////////////////////////////////
+// API
+////////////////////////////////////////////////////////////////
 
 export async function getThermalClusters(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
-): Promise<ThermalCluster[]> {
-  return makeRequest<ThermalCluster[]>("get", getClustersUrl(studyId, areaId));
+) {
+  const res = await client.get<ThermalCluster[]>(
+    getClustersUrl(studyId, areaId),
+  );
+  return res.data;
 }
 
 export async function getThermalCluster(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
   clusterId: Cluster["id"],
-): Promise<ThermalCluster> {
-  return makeRequest<ThermalCluster>(
-    "get",
+) {
+  const res = await client.get<ThermalCluster>(
     getClusterUrl(studyId, areaId, clusterId),
   );
+  return res.data;
 }
 
 export async function updateThermalCluster(
@@ -135,32 +133,44 @@ export async function updateThermalCluster(
   areaId: Area["name"],
   clusterId: Cluster["id"],
   data: Partial<ThermalCluster>,
-): Promise<ThermalCluster> {
-  return makeRequest<ThermalCluster>(
-    "patch",
+) {
+  const res = await client.patch<ThermalCluster>(
     getClusterUrl(studyId, areaId, clusterId),
     data,
   );
+  return res.data;
 }
 
 export async function createThermalCluster(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
-  data: Partial<ThermalCluster>,
-): Promise<ThermalClusterWithCapacity> {
-  return makeRequest<ThermalClusterWithCapacity>(
-    "post",
+  data: PartialExceptFor<ThermalCluster, "name">,
+) {
+  const res = await client.post<ThermalCluster>(
     getClustersUrl(studyId, areaId),
     data,
   );
+  return res.data;
+}
+
+export async function duplicateThermalCluster(
+  studyId: StudyMetadata["id"],
+  areaId: Area["name"],
+  sourceClusterId: ThermalCluster["id"],
+  newName: ThermalCluster["name"],
+) {
+  const res = await client.post<ThermalCluster>(
+    `/v1/studies/${studyId}/areas/${areaId}/thermals/${sourceClusterId}`,
+    null,
+    { params: { newName } },
+  );
+  return res.data;
 }
 
-export function deleteThermalClusters(
+export async function deleteThermalClusters(
   studyId: StudyMetadata["id"],
   areaId: Area["name"],
   clusterIds: Array<Cluster["id"]>,
-): Promise<void> {
-  return makeRequest<void>("delete", getClustersUrl(studyId, areaId), {
-    data: clusterIds,
-  });
+) {
+  await client.delete(getClustersUrl(studyId, areaId), { data: clusterIds });
 }
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/clustersUtils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/clustersUtils.ts
new file mode 100644
index 0000000000..a035dfa07f
--- /dev/null
+++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/clustersUtils.ts
@@ -0,0 +1,87 @@
+import { MRT_AggregationFn } from "material-react-table";
+import { ThermalClusterWithCapacity } from "../Thermal/utils";
+import { RenewableClusterWithCapacity } from "../Renewables/utils";
+
+/**
+ * Custom aggregation function summing the values of each row,
+ * to display enabled and installed capacity in the same cell. This function is
+ * designed for use with Material React Table's custom aggregation feature, allowing
+ * the combination of enabled and installed capacities into a single cell.
+ *
+ * @returns A string representing the sum of enabled and installed capacity in the format "enabled/installed".
+ * @example
+ * Assuming an aggregation of rows where enabled capacities sum to 100 and installed capacities sum to 200
+ * "100/200"
+ *
+ * @see https://www.material-react-table.com/docs/guides/aggregation-and-grouping#custom-aggregation-functions for more information on custom aggregation functions in Material React Table.
+ */
+export const capacityAggregationFn = <
+  T extends ThermalClusterWithCapacity | RenewableClusterWithCapacity,
+>(): MRT_AggregationFn<T> => {
+  return (columnId, leafRows) => {
+    const { enabledCapacitySum, installedCapacitySum } = leafRows.reduce(
+      (acc, row) => {
+        acc.enabledCapacitySum += row.original.enabledCapacity;
+        acc.installedCapacitySum += row.original.installedCapacity;
+
+        return acc;
+      },
+      { enabledCapacitySum: 0, installedCapacitySum: 0 },
+    );
+
+    return `${Math.floor(enabledCapacitySum)} / ${Math.floor(
+      installedCapacitySum,
+    )}`;
+  };
+};
+
+interface BaseCluster {
+  name: string;
+  group: string;
+  unitCount: number;
+  nominalCapacity: number;
+  enabled: boolean;
+}
+
+export type ClusterWithCapacity<T extends BaseCluster> = T & {
+  installedCapacity: number;
+  enabledCapacity: number;
+};
+
+/**
+ * Adds the installed and enabled capacity fields to a cluster.
+ *
+ * @param cluster - The cluster to add the capacity fields to.
+ * @returns The cluster with the installed and enabled capacity fields added.
+ */
+export function addClusterCapacity<T extends BaseCluster>(cluster: T) {
+  const { unitCount, nominalCapacity, enabled } = cluster;
+  const installedCapacity = unitCount * nominalCapacity;
+  const enabledCapacity = enabled ? installedCapacity : 0;
+  return { ...cluster, installedCapacity, enabledCapacity };
+}
+
+/**
+ * Gets the totals for unit count, installed capacity, and enabled capacity
+ * for the specified clusters.
+ *
+ * @param clusters - The clusters to get the totals for.
+ * @returns An object containing the totals.
+ */
+export function getClustersWithCapacityTotals<T extends BaseCluster>(
+  clusters: Array<ClusterWithCapacity<T>>,
+) {
+  return clusters.reduce(
+    (acc, { unitCount, installedCapacity, enabledCapacity }) => {
+      acc.totalUnitCount += unitCount;
+      acc.totalInstalledCapacity += installedCapacity;
+      acc.totalEnabledCapacity += enabledCapacity;
+      return acc;
+    },
+    {
+      totalUnitCount: 0,
+      totalInstalledCapacity: 0,
+      totalEnabledCapacity: 0,
+    },
+  );
+}
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts
deleted file mode 100644
index 9231804f53..0000000000
--- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { DependencyList, useMemo } from "react";
-import * as R from "ramda";
-import { MRT_AggregationFn } from "material-react-table";
-import { StudyMetadata } from "../../../../../../../common/types";
-import { editStudy } from "../../../../../../../services/api/study";
-import { ThermalClusterWithCapacity } from "../Thermal/utils";
-import { RenewableClusterWithCapacity } from "../Renewables/utils";
-import usePromiseWithSnackbarError from "../../../../../../../hooks/usePromiseWithSnackbarError";
-import { UsePromiseResponse } from "../../../../../../../hooks/usePromise";
-
-export const saveField = R.curry(
-  (
-    studyId: StudyMetadata["id"],
-    path: string,
-    data: Record<string, unknown>,
-  ): Promise<void> => {
-    return editStudy(data, studyId, path);
-  },
-);
-
-/**
- * Custom aggregation function summing the values of each row,
- * to display enabled and installed capacity in the same cell. This function is
- * designed for use with Material React Table's custom aggregation feature, allowing
- * the combination of enabled and installed capacities into a single cell.
- *
- * @returns A string representing the sum of enabled and installed capacity in the format "enabled/installed".
- * @example
- * Assuming an aggregation of rows where enabled capacities sum to 100 and installed capacities sum to 200
- * "100/200"
- *
- * @see https://www.material-react-table.com/docs/guides/aggregation-and-grouping#custom-aggregation-functions for more information on custom aggregation functions in Material React Table.
- */
-export const capacityAggregationFn = <
-  T extends ThermalClusterWithCapacity | RenewableClusterWithCapacity,
->(): MRT_AggregationFn<T> => {
-  return (_colHeader, rows) => {
-    const { enabledCapacitySum, installedCapacitySum } = rows.reduce(
-      (acc, row) => {
-        acc.enabledCapacitySum += row.original.enabledCapacity ?? 0;
-        acc.installedCapacitySum += row.original.installedCapacity ?? 0;
-        return acc;
-      },
-      { enabledCapacitySum: 0, installedCapacitySum: 0 },
-    );
-
-    return `${Math.floor(enabledCapacitySum)} / ${Math.floor(
-      installedCapacitySum,
-    )}`;
-  };
-};
-
-interface BaseCluster {
-  name: string;
-  group: string;
-  unitCount: number;
-  nominalCapacity: number;
-  enabled: boolean;
-}
-
-type ClusterWithCapacity<T extends BaseCluster> = T & {
-  installedCapacity: number;
-  enabledCapacity: number;
-};
-
-interface UseClusterDataWithCapacityReturn<T extends BaseCluster> {
-  clusters: UsePromiseResponse<T[]>;
-  clustersWithCapacity: Array<ClusterWithCapacity<T>>;
-  totalUnitCount: number;
-  totalInstalledCapacity: number;
-  totalEnabledCapacity: number;
-}
-
-export const useClusterDataWithCapacity = <T extends BaseCluster>(
-  fetchFn: () => Promise<T[]>,
-  errorMessage: string,
-  deps: DependencyList,
-): UseClusterDataWithCapacityReturn<T> => {
-  const clusters: UsePromiseResponse<T[]> = usePromiseWithSnackbarError(
-    fetchFn,
-    {
-      errorMessage,
-      deps,
-    },
-  );
-
-  const clustersWithCapacity: Array<ClusterWithCapacity<T>> = useMemo(
-    () =>
-      clusters.data?.map((cluster) => {
-        const { unitCount, nominalCapacity, enabled } = cluster;
-        const installedCapacity = unitCount * nominalCapacity;
-        const enabledCapacity = enabled ? installedCapacity : 0;
-        return { ...cluster, installedCapacity, enabledCapacity };
-      }) || [],
-    [clusters.data],
-  );
-
-  const { totalUnitCount, totalInstalledCapacity, totalEnabledCapacity } =
-    useMemo(() => {
-      return clustersWithCapacity.reduce(
-        (acc, { unitCount, nominalCapacity, enabled }) => {
-          acc.totalUnitCount += unitCount;
-          acc.totalInstalledCapacity += unitCount * nominalCapacity;
-          acc.totalEnabledCapacity += enabled ? unitCount * nominalCapacity : 0;
-          return acc;
-        },
-        {
-          totalUnitCount: 0,
-          totalInstalledCapacity: 0,
-          totalEnabledCapacity: 0,
-        },
-      );
-    }, [clustersWithCapacity]);
-
-  return {
-    clusters,
-    clustersWithCapacity,
-    totalUnitCount: Math.floor(totalUnitCount),
-    totalInstalledCapacity: Math.floor(totalInstalledCapacity),
-    totalEnabledCapacity: Math.floor(totalEnabledCapacity),
-  };
-};
diff --git a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx
index ec7e9149c3..256d5cfa3a 100644
--- a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx
+++ b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx
@@ -84,7 +84,6 @@ function TabWrapper({
           display: "flex",
           flexDirection: "column",
           justifyContent: "flex-start",
-          alignItems: "center",
         },
         sx,
       )}
diff --git a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx
index 5c8313a352..9df4f4e2c5 100644
--- a/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx
+++ b/webapp/src/components/common/GroupedDataTable/CreateDialog.tsx
@@ -1,48 +1,38 @@
-import { t } from "i18next";
 import AddCircleIcon from "@mui/icons-material/AddCircle";
 import FormDialog from "../dialogs/FormDialog";
 import StringFE from "../fieldEditors/StringFE";
 import Fieldset from "../Fieldset";
 import { SubmitHandlerPlus } from "../Form/types";
 import SelectFE from "../fieldEditors/SelectFE";
-import { nameToId } from "../../../services/utils";
-import { TRow } from "./utils";
 import { validateString } from "../../../utils/validationUtils";
+import type { TRow } from "./types";
+import { useTranslation } from "react-i18next";
 
-interface Props<TData extends TRow> {
+interface Props {
   open: boolean;
   onClose: VoidFunction;
-  onSubmit: (values: TData) => Promise<void>;
-  groups: string[] | readonly string[];
-  existingNames: Array<TData["name"]>;
+  onSubmit: (values: TRow) => Promise<void>;
+  groups: string[];
+  existingNames: Array<TRow["name"]>;
 }
 
-const defaultValues = {
-  name: "",
-  group: "",
-};
-
-function CreateDialog<TData extends TRow>({
+function CreateDialog({
   open,
   onClose,
   onSubmit,
   groups,
   existingNames,
-}: Props<TData>) {
+}: Props) {
+  const { t } = useTranslation();
+
   ////////////////////////////////////////////////////////////////
   // Event Handlers
   ////////////////////////////////////////////////////////////////
 
-  const handleSubmit = async ({
-    values,
-  }: SubmitHandlerPlus<typeof defaultValues>) => {
-    await onSubmit({
-      ...values,
-      id: nameToId(values.name),
-      name: values.name.trim(),
-    } as TData);
-
-    onClose();
+  const handleSubmit = ({
+    values: { name, group },
+  }: SubmitHandlerPlus<TRow>) => {
+    return onSubmit({ name: name.trim(), group });
   };
 
   ////////////////////////////////////////////////////////////////
@@ -56,7 +46,6 @@ function CreateDialog<TData extends TRow>({
       open={open}
       onCancel={onClose}
       onSubmit={handleSubmit}
-      config={{ defaultValues }}
     >
       {({ control }) => (
         <Fieldset fullFieldWidth>
@@ -72,14 +61,11 @@ function CreateDialog<TData extends TRow>({
             sx={{ m: 0 }}
           />
           <SelectFE
-            label={t("study.modelization.clusters.group")}
+            label={t("global.group")}
             name="group"
             control={control}
             options={groups}
-            required
-            sx={{
-              alignSelf: "center",
-            }}
+            rules={{ required: t("form.field.required") }}
           />
         </Fieldset>
       )}
diff --git a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx
index 34664b8f3a..099748ef9a 100644
--- a/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx
+++ b/webapp/src/components/common/GroupedDataTable/DuplicateDialog.tsx
@@ -1,5 +1,5 @@
 import { useTranslation } from "react-i18next";
-import ControlPointDuplicateIcon from "@mui/icons-material/ControlPointDuplicate";
+import ContentCopyIcon from "@mui/icons-material/ContentCopy";
 import Fieldset from "../Fieldset";
 import FormDialog from "../dialogs/FormDialog";
 import { SubmitHandlerPlus } from "../Form/types";
@@ -38,7 +38,7 @@ function DuplicateDialog(props: Props) {
     <FormDialog
       open={open}
       title={t("global.duplicate")}
-      titleIcon={ControlPointDuplicateIcon}
+      titleIcon={ContentCopyIcon}
       onCancel={onClose}
       onSubmit={handleSubmit}
       config={{ defaultValues }}
diff --git a/webapp/src/components/common/GroupedDataTable/cellRenderers/BooleanCell.tsx b/webapp/src/components/common/GroupedDataTable/cellRenderers/BooleanCell.tsx
new file mode 100644
index 0000000000..deb28e01a2
--- /dev/null
+++ b/webapp/src/components/common/GroupedDataTable/cellRenderers/BooleanCell.tsx
@@ -0,0 +1,22 @@
+import { Chip } from "@mui/material";
+import type { MRT_Cell, MRT_RowData } from "material-react-table";
+import { useTranslation } from "react-i18next";
+
+interface Props<T extends MRT_RowData> {
+  cell: MRT_Cell<T, boolean>;
+}
+
+function BooleanCell<T extends MRT_RowData>({ cell }: Props<T>) {
+  const { t } = useTranslation();
+
+  return (
+    <Chip
+      label={cell.getValue() ? t("button.yes") : t("button.no")}
+      color={cell.getValue() ? "success" : "error"}
+      size="small"
+      sx={{ minWidth: 40 }}
+    />
+  );
+}
+
+export default BooleanCell;
diff --git a/webapp/src/components/common/GroupedDataTable/index.tsx b/webapp/src/components/common/GroupedDataTable/index.tsx
index 5bc91534f9..6aed12cc32 100644
--- a/webapp/src/components/common/GroupedDataTable/index.tsx
+++ b/webapp/src/components/common/GroupedDataTable/index.tsx
@@ -1,69 +1,303 @@
 import Box from "@mui/material/Box";
 import AddCircleOutlineIcon from "@mui/icons-material/AddCircleOutline";
-import ControlPointDuplicateIcon from "@mui/icons-material/ControlPointDuplicate";
+import ContentCopyIcon from "@mui/icons-material/ContentCopy";
 import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
 import DeleteIcon from "@mui/icons-material/Delete";
-import { Button } from "@mui/material";
+import { Button, Skeleton } from "@mui/material";
 import {
   MaterialReactTable,
   MRT_ToggleFiltersButton,
   MRT_ToggleGlobalFilterButton,
+  useMaterialReactTable,
   type MRT_RowSelectionState,
   type MRT_ColumnDef,
 } from "material-react-table";
 import { useTranslation } from "react-i18next";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
 import CreateDialog from "./CreateDialog";
 import ConfirmationDialog from "../dialogs/ConfirmationDialog";
-import { TRow, generateUniqueValue } from "./utils";
+import { generateUniqueValue, getTableOptionsForAlign } from "./utils";
 import DuplicateDialog from "./DuplicateDialog";
+import { translateWithColon } from "../../../utils/i18nUtils";
+import useAutoUpdateRef from "../../../hooks/useAutoUpdateRef";
+import * as R from "ramda";
+import * as RA from "ramda-adjunct";
+import { PromiseAny } from "../../../utils/tsUtils";
+import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar";
+import { toError } from "../../../utils/fnUtils";
+import useOperationInProgressCount from "../../../hooks/useOperationInProgressCount";
+import type { TRow } from "./types";
 
-export interface GroupedDataTableProps<TData extends TRow> {
+export interface GroupedDataTableProps<
+  TGroups extends string[],
+  TData extends TRow<TGroups[number]>,
+> {
   data: TData[];
-  columns: Array<MRT_ColumnDef<TData>>;
-  groups: string[] | readonly string[];
-  onCreate?: (values: TData) => Promise<TData>;
-  onDelete?: (ids: string[]) => void;
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  columns: Array<MRT_ColumnDef<TData, any>>;
+  groups: TGroups;
+  onCreate?: (values: TRow<TGroups[number]>) => Promise<TData>;
+  onDuplicate?: (row: TData, newName: string) => Promise<TData>;
+  onDelete?: (rows: TData[]) => PromiseAny | void;
+  onNameClick?: (row: TData) => void;
+  onDataChange?: (data: TData[]) => void;
+  isLoading?: boolean;
+  deleteConfirmationMessage?: string | ((count: number) => string);
+  fillPendingRow?: (
+    pendingRow: TRow<TGroups[number]>,
+  ) => TRow<TGroups[number]> & Partial<TData>;
 }
 
-function GroupedDataTable<TData extends TRow>({
+// Use ids to identify default columns (instead of `accessorKey`),
+// to have a unique identifier. It is more likely to have a duplicate
+// `accessorKey` with `columns` prop.
+const GROUP_COLUMN_ID = "_group";
+const NAME_COLUMN_ID = "_name";
+
+function GroupedDataTable<
+  TGroups extends string[],
+  TData extends TRow<TGroups[number]>,
+>({
   data,
   columns,
   groups,
   onCreate,
+  onDuplicate,
   onDelete,
-}: GroupedDataTableProps<TData>) {
+  onNameClick,
+  onDataChange,
+  isLoading,
+  deleteConfirmationMessage,
+  fillPendingRow,
+}: GroupedDataTableProps<TGroups, TData>) {
   const { t } = useTranslation();
   const [openDialog, setOpenDialog] = useState<
     "add" | "duplicate" | "delete" | ""
   >("");
   const [tableData, setTableData] = useState(data);
   const [rowSelection, setRowSelection] = useState<MRT_RowSelectionState>({});
+  const enqueueErrorSnackbar = useEnqueueErrorSnackbar();
+  // Allow to use the last version of `onNameClick` in `tableColumns`
+  const callbacksRef = useAutoUpdateRef({ onNameClick });
+  const pendingRows = useRef<Array<TRow<TGroups[number]>>>([]);
+  const { createOps, deleteOps, totalOps } = useOperationInProgressCount();
 
-  const isAnyRowSelected = useMemo(
-    () => Object.values(rowSelection).some((value) => value),
-    [rowSelection],
-  );
+  useEffect(() => setTableData(data), [data]);
 
-  const isOneRowSelected = useMemo(
-    () => Object.values(rowSelection).filter((value) => value).length === 1,
-    [rowSelection],
-  );
-
-  const selectedRow = useMemo(() => {
-    if (isOneRowSelected) {
-      const selectedIndex = Object.keys(rowSelection).find(
-        (key) => rowSelection[key],
-      );
-      return selectedIndex && tableData[+selectedIndex];
-    }
-  }, [isOneRowSelected, rowSelection, tableData]);
+  // eslint-disable-next-line react-hooks/exhaustive-deps
+  useEffect(() => onDataChange?.(tableData), [tableData]);
 
   const existingNames = useMemo(
     () => tableData.map((row) => row.name.toLowerCase()),
     [tableData],
   );
 
+  const tableColumns = useMemo<Array<MRT_ColumnDef<TData>>>(
+    () => [
+      {
+        accessorKey: "group",
+        header: t("global.group"),
+        id: GROUP_COLUMN_ID,
+        size: 50,
+        filterVariant: "autocomplete",
+        filterSelectOptions: groups,
+        footer: translateWithColon("global.total"),
+        ...getTableOptionsForAlign("left"),
+      },
+      {
+        accessorKey: "name",
+        header: t("global.name"),
+        id: NAME_COLUMN_ID,
+        size: 100,
+        filterVariant: "autocomplete",
+        filterSelectOptions: existingNames,
+        Cell:
+          callbacksRef.current.onNameClick &&
+          (({ renderedCellValue, row }) => {
+            if (isPendingRow(row.original)) {
+              return renderedCellValue;
+            }
+
+            return (
+              <Box
+                sx={{
+                  display: "inline",
+                  "&:hover": {
+                    color: "primary.main",
+                    textDecoration: "underline",
+                  },
+                }}
+                onClick={() => callbacksRef.current.onNameClick?.(row.original)}
+              >
+                {renderedCellValue}
+              </Box>
+            );
+          }),
+        ...getTableOptionsForAlign("left"),
+      },
+      ...columns.map(
+        (column) =>
+          ({
+            ...column,
+            Cell: (props) => {
+              const { row, renderedCellValue } = props;
+              // Use JSX instead of call it directly to remove React warning:
+              // 'Warning: Internal React error: Expected static flag was missing.'
+              const CellComp = column.Cell;
+
+              if (isPendingRow(row.original)) {
+                return (
+                  <Skeleton
+                    width={80}
+                    height={24}
+                    sx={{ display: "inline-block" }}
+                  />
+                );
+              }
+
+              return CellComp ? <CellComp {...props} /> : renderedCellValue;
+            },
+          }) as MRT_ColumnDef<TData>,
+      ),
+    ],
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [columns, t, ...groups],
+  );
+
+  const table = useMaterialReactTable({
+    data: tableData,
+    columns: tableColumns,
+    initialState: {
+      grouping: [GROUP_COLUMN_ID],
+      density: "compact",
+      expanded: true,
+      columnPinning: { left: [GROUP_COLUMN_ID] },
+    },
+    state: { isLoading, isSaving: totalOps > 0, rowSelection },
+    enableGrouping: true,
+    enableStickyFooter: true,
+    enableStickyHeader: true,
+    enableColumnDragging: false,
+    enableColumnActions: false,
+    enableBottomToolbar: false,
+    enablePagination: false,
+    positionToolbarAlertBanner: "none",
+    // Rows
+    muiTableBodyRowProps: ({ row }) => {
+      const isPending = isPendingRow(row.original);
+
+      return {
+        onClick: () => {
+          if (isPending) {
+            return;
+          }
+
+          const isGrouped = row.getIsGrouped();
+          const rowIds = isGrouped
+            ? row.getLeafRows().map((r) => r.id)
+            : [row.id];
+
+          setRowSelection((prev) => {
+            const newValue = isGrouped
+              ? !rowIds.some((id) => prev[id]) // Select/Deselect all
+              : !prev[row.id];
+
+            return {
+              ...prev,
+              ...rowIds.reduce((acc, id) => ({ ...acc, [id]: newValue }), {}),
+            };
+          });
+        },
+        selected: rowSelection[row.id],
+        sx: { cursor: isPending ? "wait" : "pointer" },
+      };
+    },
+    // Toolbars
+    renderTopToolbarCustomActions: ({ table }) => (
+      <Box sx={{ display: "flex", gap: 1 }}>
+        {onCreate && (
+          <Button
+            startIcon={<AddCircleOutlineIcon />}
+            variant="contained"
+            size="small"
+            onClick={() => setOpenDialog("add")}
+          >
+            {t("button.add")}
+          </Button>
+        )}
+        {onDuplicate && (
+          <Button
+            startIcon={<ContentCopyIcon />}
+            variant="outlined"
+            size="small"
+            onClick={() => setOpenDialog("duplicate")}
+            disabled={table.getSelectedRowModel().rows.length !== 1}
+          >
+            {t("global.duplicate")}
+          </Button>
+        )}
+        {onDelete && (
+          <Button
+            startIcon={<DeleteOutlineIcon />}
+            color="error"
+            variant="outlined"
+            size="small"
+            onClick={() => setOpenDialog("delete")}
+            disabled={table.getSelectedRowModel().rows.length === 0}
+          >
+            {t("global.delete")}
+          </Button>
+        )}
+      </Box>
+    ),
+    renderToolbarInternalActions: ({ table }) => (
+      <>
+        <MRT_ToggleGlobalFilterButton table={table} />
+        <MRT_ToggleFiltersButton table={table} />
+      </>
+    ),
+    onRowSelectionChange: setRowSelection,
+    // Styles
+    muiTablePaperProps: { sx: { display: "flex", flexDirection: "column" } }, // Allow to have scroll
+    ...R.mergeDeepRight(getTableOptionsForAlign("right"), {
+      muiTableBodyCellProps: {
+        sx: { borderBottom: "1px solid rgba(224, 224, 224, 0.3)" },
+      },
+    }),
+  });
+
+  const selectedRows = table
+    .getSelectedRowModel()
+    .rows.map((row) => row.original);
+  const selectedRow = selectedRows.length === 1 ? selectedRows[0] : null;
+
+  ////////////////////////////////////////////////////////////////
+  // Optimistic
+  ////////////////////////////////////////////////////////////////
+
+  const addPendingRow = (row: TRow<TGroups[number]>) => {
+    const pendingRow = fillPendingRow?.(row) || row;
+
+    pendingRows.current.push(pendingRow);
+
+    // Type can be asserted as `TData` because the row will be checked in cell renders
+    // and `fillPendingRow` allows to add needed data
+    setTableData((prev) => [...prev, pendingRow as TData]);
+
+    return pendingRow;
+  };
+
+  const removePendingRow = (row: TRow<TGroups[number]>) => {
+    if (isPendingRow(row)) {
+      pendingRows.current = pendingRows.current.filter((r) => r !== row);
+      setTableData((prev) => prev.filter((r) => r !== row));
+    }
+  };
+
+  function isPendingRow(row: TRow<TGroups[number]>) {
+    return pendingRows.current.includes(row);
+  }
+
   ////////////////////////////////////////////////////////////////
   // Utils
   ////////////////////////////////////////////////////////////////
@@ -74,51 +308,80 @@ function GroupedDataTable<TData extends TRow>({
   // Event Handlers
   ////////////////////////////////////////////////////////////////
 
-  const handleCreate = async (values: TData) => {
-    if (onCreate) {
-      const newRow = await onCreate(values);
-      setTableData((prevTableData) => [...prevTableData, newRow]);
-    }
-  };
+  const handleCreate = async (values: TRow<TGroups[number]>) => {
+    closeDialog();
 
-  const handleDelete = () => {
-    if (!onDelete) {
+    if (!onCreate) {
       return;
     }
 
-    const rowIndexes = Object.keys(rowSelection)
-      .map(Number)
-      // ignore groups names
-      .filter(Number.isInteger);
+    createOps.increment();
+    const pendingRow = addPendingRow(values);
 
-    const rowIdsToDelete = rowIndexes.map((index) => tableData[index].id);
+    try {
+      const newRow = await onCreate(values);
+      setTableData((prev) => [...prev, newRow]);
+    } catch (error) {
+      enqueueErrorSnackbar(t("global.error.create"), toError(error));
+    }
 
-    onDelete(rowIdsToDelete);
-    setTableData((prevTableData) =>
-      prevTableData.filter((row) => !rowIdsToDelete.includes(row.id)),
-    );
-    setRowSelection({});
-    closeDialog();
+    removePendingRow(pendingRow);
+    createOps.decrement();
   };
 
-  const handleDuplicate = async (name: string) => {
-    if (!selectedRow) {
+  const handleDuplicate = async (newName: string) => {
+    closeDialog();
+
+    if (!onDuplicate || !selectedRow) {
       return;
     }
 
-    const id = generateUniqueValue("id", name, tableData);
+    setRowSelection({});
 
     const duplicatedRow = {
       ...selectedRow,
-      id,
-      name,
+      name: newName,
     };
 
-    if (onCreate) {
-      const newRow = await onCreate(duplicatedRow);
-      setTableData((prevTableData) => [...prevTableData, newRow]);
-      setRowSelection({});
+    createOps.increment();
+    const pendingRow = addPendingRow(duplicatedRow);
+
+    try {
+      const newRow = await onDuplicate(selectedRow, newName);
+      setTableData((prev) => [...prev, newRow]);
+    } catch (error) {
+      enqueueErrorSnackbar(t("global.error.create"), toError(error));
     }
+
+    removePendingRow(pendingRow);
+    createOps.decrement();
+  };
+
+  const handleDelete = async () => {
+    closeDialog();
+
+    if (!onDelete) {
+      return;
+    }
+
+    setRowSelection({});
+
+    const rowsToDelete = selectedRows;
+
+    setTableData((prevTableData) =>
+      prevTableData.filter((row) => !rowsToDelete.includes(row)),
+    );
+
+    deleteOps.increment();
+
+    try {
+      await onDelete(rowsToDelete);
+    } catch (error) {
+      enqueueErrorSnackbar(t("global.error.delete"), toError(error));
+      setTableData((prevTableData) => [...prevTableData, ...rowsToDelete]);
+    }
+
+    deleteOps.decrement();
   };
 
   ////////////////////////////////////////////////////////////////
@@ -127,106 +390,7 @@ function GroupedDataTable<TData extends TRow>({
 
   return (
     <>
-      <MaterialReactTable
-        data={tableData}
-        columns={columns}
-        initialState={{
-          grouping: ["group"],
-          density: "compact",
-          expanded: true,
-          columnPinning: { left: ["group"] },
-        }}
-        enablePinning
-        enableExpanding
-        enableGrouping
-        muiTableBodyRowProps={({ row: { id, groupingColumnId } }) => {
-          const handleRowClick = () => {
-            // prevent group rows to be selected
-            if (groupingColumnId === undefined) {
-              setRowSelection((prev) => ({
-                ...prev,
-                [id]: !prev[id],
-              }));
-            }
-          };
-
-          return {
-            onClick: handleRowClick,
-            selected: rowSelection[id],
-            sx: {
-              cursor: "pointer",
-            },
-          };
-        }}
-        state={{ rowSelection }}
-        enableColumnDragging={false}
-        enableColumnActions={false}
-        positionToolbarAlertBanner="none"
-        enableBottomToolbar={false}
-        enableStickyFooter
-        enableStickyHeader
-        enablePagination={false}
-        renderTopToolbarCustomActions={() => (
-          <Box sx={{ display: "flex", gap: 1 }}>
-            {onCreate && (
-              <Button
-                startIcon={<AddCircleOutlineIcon />}
-                variant="contained"
-                size="small"
-                onClick={() => setOpenDialog("add")}
-              >
-                {t("button.add")}
-              </Button>
-            )}
-            <Button
-              startIcon={<ControlPointDuplicateIcon />}
-              variant="outlined"
-              size="small"
-              onClick={() => setOpenDialog("duplicate")}
-              disabled={!isOneRowSelected}
-            >
-              {t("global.duplicate")}
-            </Button>
-            {onDelete && (
-              <Button
-                startIcon={<DeleteOutlineIcon />}
-                variant="outlined"
-                size="small"
-                onClick={() => setOpenDialog("delete")}
-                disabled={!isAnyRowSelected}
-              >
-                {t("global.delete")}
-              </Button>
-            )}
-          </Box>
-        )}
-        renderToolbarInternalActions={({ table }) => (
-          <>
-            <MRT_ToggleGlobalFilterButton table={table} />
-            <MRT_ToggleFiltersButton table={table} />
-          </>
-        )}
-        muiTableHeadCellProps={{
-          align: "right",
-        }}
-        muiTableBodyCellProps={{
-          align: "right",
-          sx: {
-            borderBottom: "1px solid rgba(224, 224, 224, 0.3)",
-          },
-        }}
-        muiTableFooterCellProps={{
-          align: "right",
-        }}
-        muiTablePaperProps={{
-          sx: {
-            width: 1,
-            display: "flex",
-            flexDirection: "column",
-            overflow: "auto",
-          },
-        }}
-      />
+      <MaterialReactTable table={table} />
       {openDialog === "add" && (
         <CreateDialog
           open
@@ -242,7 +406,7 @@ function GroupedDataTable<TData extends TRow>({
           onClose={closeDialog}
           onSubmit={handleDuplicate}
           existingNames={existingNames}
-          defaultName={generateUniqueValue("name", selectedRow.name, tableData)}
+          defaultName={generateUniqueValue(selectedRow.name, tableData)}
         />
       )}
       {openDialog === "delete" && (
@@ -254,7 +418,9 @@ function GroupedDataTable<TData extends TRow>({
           onConfirm={handleDelete}
           alert="warning"
         >
-          {t("studies.modelization.clusters.question.delete")}
+          {RA.isFunction(deleteConfirmationMessage)
+            ? deleteConfirmationMessage(selectedRows.length)
+            : deleteConfirmationMessage ?? t("dialog.message.confirmDelete")}
         </ConfirmationDialog>
       )}
     </>
diff --git a/webapp/src/components/common/GroupedDataTable/types.ts b/webapp/src/components/common/GroupedDataTable/types.ts
new file mode 100644
index 0000000000..6f91852cb4
--- /dev/null
+++ b/webapp/src/components/common/GroupedDataTable/types.ts
@@ -0,0 +1,4 @@
+export interface TRow<T = string> {
+  name: string;
+  group: T;
+}
diff --git a/webapp/src/components/common/GroupedDataTable/utils.ts b/webapp/src/components/common/GroupedDataTable/utils.ts
index df119f0fa8..f209d83bae 100644
--- a/webapp/src/components/common/GroupedDataTable/utils.ts
+++ b/webapp/src/components/common/GroupedDataTable/utils.ts
@@ -1,15 +1,6 @@
 import * as R from "ramda";
-import { nameToId } from "../../../services/utils";
-
-////////////////////////////////////////////////////////////////
-// Types
-////////////////////////////////////////////////////////////////
-
-export interface TRow {
-  id: string;
-  name: string;
-  group: string;
-}
+import { TableCellProps } from "@mui/material";
+import type { TRow } from "./types";
 
 ////////////////////////////////////////////////////////////////
 // Functions
@@ -58,24 +49,22 @@ export const generateNextValue = (
  *
  * This function leverages `generateNextValue` to ensure the uniqueness of the value.
  *
- * @param property - The property for which the unique value is generated, either "name" or "id".
  * @param originalValue - The original value of the specified property.
  * @param tableData - The existing table data to check against for ensuring uniqueness.
  * @returns A unique value for the specified property.
  */
 export const generateUniqueValue = (
-  property: "name" | "id",
   originalValue: string,
   tableData: TRow[],
 ): string => {
-  let baseValue: string;
-
-  if (property === "name") {
-    baseValue = `${originalValue} - copy`;
-  } else {
-    baseValue = nameToId(originalValue);
-  }
-
-  const existingValues = tableData.map((row) => row[property]);
-  return generateNextValue(baseValue, existingValues);
+  const existingValues = tableData.map((row) => row.name);
+  return generateNextValue(`${originalValue} - copy`, existingValues);
 };
+
+export function getTableOptionsForAlign(align: TableCellProps["align"]) {
+  return {
+    muiTableHeadCellProps: { align },
+    muiTableBodyCellProps: { align },
+    muiTableFooterCellProps: { align },
+  };
+}
diff --git a/webapp/src/hooks/useOperationInProgressCount.ts b/webapp/src/hooks/useOperationInProgressCount.ts
new file mode 100644
index 0000000000..bc71fb677a
--- /dev/null
+++ b/webapp/src/hooks/useOperationInProgressCount.ts
@@ -0,0 +1,51 @@
+import { useMemo, useState } from "react";
+import * as R from "ramda";
+
+/**
+ * Hook to tracks the number of CRUD operations in progress.
+ *
+ * @returns An object containing methods to increment, decrement,
+ * and retrieve the count of each operation type.
+ */
+function useOperationInProgressCount() {
+  const [opsInProgressCount, setOpsInProgressCount] = useState({
+    create: 0,
+    read: 0,
+    update: 0,
+    delete: 0,
+  });
+
+  const makeOperationMethods = (
+    operation: keyof typeof opsInProgressCount,
+  ) => ({
+    increment: (number = 1) => {
+      setOpsInProgressCount((prev) => ({
+        ...prev,
+        [operation]: prev[operation] + number,
+      }));
+    },
+    decrement: (number = 1) => {
+      setOpsInProgressCount((prev) => ({
+        ...prev,
+        [operation]: Math.max(prev[operation] - number, 0),
+      }));
+    },
+    total: opsInProgressCount[operation],
+  });
+
+  const methods = useMemo(
+    () => ({
+      createOps: makeOperationMethods("create"),
+      readOps: makeOperationMethods("read"),
+      updateOps: makeOperationMethods("update"),
+      deleteOps: makeOperationMethods("delete"),
+      totalOps: Object.values(opsInProgressCount).reduce(R.add, 0),
+    }),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [opsInProgressCount],
+  );
+
+  return methods;
+}
+
+export default useOperationInProgressCount;
diff --git a/webapp/src/hooks/useUpdateEffectOnce.ts b/webapp/src/hooks/useUpdateEffectOnce.ts
new file mode 100644
index 0000000000..61fedd115e
--- /dev/null
+++ b/webapp/src/hooks/useUpdateEffectOnce.ts
@@ -0,0 +1,23 @@
+import { useEffect, useRef } from "react";
+import { useUpdateEffect } from "react-use";
+
+/**
+ * Hook that runs the effect only at the first dependencies update.
+ * It behaves like the `useEffect` hook, but it skips the initial run,
+ * and the runs following the first update.
+ *
+ * @param effect - The effect function to run.
+ * @param deps - An array of dependencies to watch for changes.
+ */
+const useUpdateEffectOnce: typeof useEffect = (effect, deps) => {
+  const hasUpdated = useRef(false);
+
+  useUpdateEffect(() => {
+    if (!hasUpdated.current) {
+      hasUpdated.current = true;
+      return effect();
+    }
+  }, deps);
+};
+
+export default useUpdateEffectOnce;
diff --git a/webapp/src/i18n.ts b/webapp/src/i18n.ts
index d1d95a0574..980cffbf89 100644
--- a/webapp/src/i18n.ts
+++ b/webapp/src/i18n.ts
@@ -2,34 +2,35 @@ import i18n from "i18next";
 import Backend from "i18next-http-backend";
 import LanguageDetector from "i18next-browser-languagedetector";
 import { initReactI18next } from "react-i18next";
+import { version } from "../package.json";
 
-export default function i18nInit(version = "unknown") {
-  i18n
-    // load translation using xhr -> see /public/locales
-    // learn more: https://github.com/i18next/i18next-xhr-backend
-    .use(Backend)
-    // detect user language
-    // learn more: https://github.com/i18next/i18next-browser-languageDetector
-    .use(LanguageDetector)
-    // pass the i18n instance to react-i18next.
-    .use(initReactI18next)
-    // init i18next
-    // for all options read: https://www.i18next.com/overview/configuration-options
-    .init({
-      fallbackLng: "en",
-      backend: {
-        loadPath: `${
-          import.meta.env.BASE_URL
-        }locales/{{lng}}/{{ns}}.json?v=${version}`,
-      },
-      react: {
-        useSuspense: false,
-      },
-      interpolation: {
-        escapeValue: false, // not needed for react as it escapes by default
-      },
-      ns: ["main"],
-      defaultNS: "main",
-      returnNull: false,
-    });
-}
+i18n
+  // load translation using xhr -> see /public/locales
+  // learn more: https://github.com/i18next/i18next-xhr-backend
+  .use(Backend)
+  // detect user language
+  // learn more: https://github.com/i18next/i18next-browser-languageDetector
+  .use(LanguageDetector)
+  // pass the i18n instance to react-i18next.
+  .use(initReactI18next)
+  // init i18next
+  // for all options read: https://www.i18next.com/overview/configuration-options
+  .init({
+    fallbackLng: "en",
+    backend: {
+      loadPath: `${
+        import.meta.env.BASE_URL
+      }locales/{{lng}}/{{ns}}.json?v=${version}`,
+    },
+    react: {
+      useSuspense: false,
+    },
+    interpolation: {
+      escapeValue: false, // not needed for react as it escapes by default
+    },
+    ns: ["main"],
+    defaultNS: "main",
+    returnNull: false,
+  });
+
+export default i18n;
diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx
index 80dec85813..2c6792f7a8 100644
--- a/webapp/src/index.tsx
+++ b/webapp/src/index.tsx
@@ -1,7 +1,6 @@
 import { createRoot } from "react-dom/client";
 import { Provider } from "react-redux";
 import { StyledEngineProvider } from "@mui/material";
-import i18nInit from "./i18n";
 import "./index.css";
 import App from "./components/App";
 import { Config, initConfig } from "./services/config";
@@ -15,8 +14,6 @@ initConfig((config: Config) => {
     window.location.reload();
   }
 
-  i18nInit(config.version.gitcommit);
-
   const container = document.getElementById("root") as HTMLElement;
   const root = createRoot(container);
 
diff --git a/webapp/src/utils/fnUtils.ts b/webapp/src/utils/fnUtils.ts
index 226e58c836..dd32ac4c97 100644
--- a/webapp/src/utils/fnUtils.ts
+++ b/webapp/src/utils/fnUtils.ts
@@ -11,3 +11,22 @@
 export function voidFn<TArgs extends unknown[]>(...args: TArgs) {
   // Intentionally empty, as its purpose is to do nothing.
 }
+
+/**
+ * A utility function that converts an unknown value to an Error object.
+ * If the value is already an Error object, it is returned as is.
+ * If the value is a string, it is used as the message for the new Error object.
+ * If the value is anything else, a new Error object with a generic message is created.
+ *
+ * @param error - The value to convert to an Error object.
+ * @returns An Error object.
+ */
+export function toError(error: unknown) {
+  if (error instanceof Error) {
+    return error;
+  }
+  if (typeof error === "string") {
+    return new Error(error);
+  }
+  return new Error("An unknown error occurred");
+}
diff --git a/webapp/src/utils/i18nUtils.ts b/webapp/src/utils/i18nUtils.ts
new file mode 100644
index 0000000000..c613deab68
--- /dev/null
+++ b/webapp/src/utils/i18nUtils.ts
@@ -0,0 +1,22 @@
+import i18n from "../i18n";
+
+/**
+ * Gets the current language used in the application.
+ *
+ * @returns The current language.
+ */
+export function getCurrentLanguage() {
+  return i18n.language;
+}
+
+/**
+ * Translates the given key and appends a colon (:) at the end
+ * with the appropriate spacing for the current language.
+ *
+ * @param key - The translation key.
+ * @returns The translated string with a colon (:) appended.
+ */
+export function translateWithColon(key: string): string {
+  const lang = i18n.language;
+  return `${i18n.t(key)}${lang.startsWith("fr") ? " " : ""}:`;
+}
diff --git a/webapp/src/utils/tsUtils.ts b/webapp/src/utils/tsUtils.ts
index eb60713aa8..7acf6465a2 100644
--- a/webapp/src/utils/tsUtils.ts
+++ b/webapp/src/utils/tsUtils.ts
@@ -1,3 +1,16 @@
+import { O } from "ts-toolbelt";
+
+/**
+ * Allow to use `any` with `Promise` type without disabling ESLint rule.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type PromiseAny = Promise<any>;
+
+/**
+ * Make all properties in T optional, except for those specified by K.
+ */
+export type PartialExceptFor<T, K extends keyof T> = O.Required<Partial<T>, K>;
+
 export function tuple<T extends unknown[]>(...items: T): T {
   return items;
 }
diff --git a/webapp/src/utils/validationUtils.ts b/webapp/src/utils/validationUtils.ts
index 94f1f95c30..9af316cbba 100644
--- a/webapp/src/utils/validationUtils.ts
+++ b/webapp/src/utils/validationUtils.ts
@@ -99,7 +99,7 @@ export function validateString(
 
   // Check for duplication against existing values.
   if (existingValues.map(normalize).includes(comparisonValue)) {
-    return t("form.field.duplicate", { 0: value });
+    return t("form.field.duplicate");
   }
 
   // Check for inclusion in the list of excluded values.