diff --git a/.changeset/flat-dogs-wink.md b/.changeset/flat-dogs-wink.md
new file mode 100644
index 0000000..09c349c
--- /dev/null
+++ b/.changeset/flat-dogs-wink.md
@@ -0,0 +1,5 @@
+---
+"@openfun/cunningham-react": minor
+---
+
+add useControllableState hook
diff --git a/packages/react/src/hooks/useControllableState.spec.tsx b/packages/react/src/hooks/useControllableState.spec.tsx
new file mode 100644
index 0000000..9a4333f
--- /dev/null
+++ b/packages/react/src/hooks/useControllableState.spec.tsx
@@ -0,0 +1,75 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useControllableState } from ":/hooks/useControllableState";
+import { Button } from ":/components/Button";
+
+describe("useControllableState", () => {
+ const TestComponent = (props: {
+ value?: string;
+ setValue?: (value: string) => void;
+ }) => {
+ const [value, setValue] = useControllableState(
+ "default",
+ props.value,
+ props.setValue,
+ );
+
+ return (
+
+
Controllable value: {value}|
+
+
+ );
+ };
+
+ it("works non controlled", async () => {
+ render();
+
+ screen.getByText("Controllable value: default|");
+
+ // Change internal state.
+ const $button = screen.getByRole("button", { name: "Set value" });
+ await userEvent.click($button);
+ screen.getByText("Controllable value: custom value|");
+ });
+
+ it("work controlled", async () => {
+ const Wrapper = () => {
+ const [value, setValue] = React.useState("controlled value");
+ return (
+ <>
+
+
+ Controlled value: {value}|
+ >
+ );
+ };
+ render();
+
+ const user = userEvent.setup();
+
+ // Default value is controlled.
+ screen.getByText("Controllable value: controlled value|");
+ screen.getByText("Controlled value: controlled value|");
+
+ // Set value from parent.
+ const $button = screen.getByRole("button", {
+ name: "Set controlled value",
+ });
+
+ await user.click($button);
+ screen.getByText("Controllable value: new controlled value|");
+ screen.getByText("Controlled value: new controlled value|");
+
+ // Set value from child.
+ const $buttonNested = screen.getByRole("button", {
+ name: "Set value",
+ });
+ await user.click($buttonNested);
+ screen.getByText("Controllable value: custom value|");
+ screen.getByText("Controlled value: custom value|");
+ });
+});
diff --git a/packages/react/src/hooks/useControllableState.ts b/packages/react/src/hooks/useControllableState.ts
new file mode 100644
index 0000000..ae7581b
--- /dev/null
+++ b/packages/react/src/hooks/useControllableState.ts
@@ -0,0 +1,39 @@
+import React, { useEffect } from "react";
+
+/**
+ * This hook is used to create a state that can be controlled by the parent. If not the state is handled internally.
+ *
+ * @param defaultValue if not controlled by the parent, this is the default value
+ * @param propsValue if controlled by the parent, this is the value
+ * @param propsCallback if controlled by the parent, this is the callback to call when the value changes
+ */
+export const useControllableState = (
+ defaultValue: T,
+ propsValue?: T,
+ propsCallback?: (value: T) => void,
+): [T, (value: T) => void] => {
+ const [state, setState] = React.useState(
+ typeof propsValue === "undefined" ? defaultValue : propsValue,
+ );
+
+ // Bottom-Up.
+ const onChange = (value: T) => {
+ if (propsCallback) {
+ propsCallback(value);
+ } else {
+ setState(value);
+ }
+ };
+
+ // Top-Down.
+ useEffect(() => {
+ if (!propsCallback) {
+ return;
+ }
+ if (typeof propsValue !== "undefined" && propsValue !== state) {
+ setState(propsValue);
+ }
+ }, [propsValue]);
+
+ return [state, onChange];
+};