Repository: marimo-team/marimo
Pull request: fix: Update plugin state on Python argument changes
## 📝 Summary

Fixes #2587
Fixes #2648

## 🔍 Description of Changes

Noticed that some plugins were not updating with changes to the initialization arguments. `DataTablePlugin` handled this using effects, so I just extended that for the args that were not updating state.

- Update `mo.ui.table` on change to `freeze_columns_left` or `freeze_columns_right`.
- Update `mo.ui.file_browser` on change to `initial_path`, `multiple`, `restrict_navigation`, and `selection_mode`.

## 📋 Checklist

- [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md).
- [x] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://discord.gg/JE7nhX6mD8), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable).
- [ ] I have added tests for the changes made.
- [x] I have run the code and verified that it works as expected.

## 📜 Reviewers

@akshayka OR @mscolnick


Problem statement:
some marimo components do not update with some arguments
### Describe the bug

When updating certain arguments for components like `mo.ui.table` and `mo.ui.file_browser`, the outputted component is not re-rendered.

For example, running a cell with:
```py
mo.ui.table(df)
```
And then updating the code to:
```py
mo.ui.table(df, freeze_columns_left=["name"])
```
will not freeze a column to the left.

However, things like page size, label, etc. work fine. Expect behavior to be the same as components such as dropdown or slider, where updating the options or min/max will immediately update the component.

Might have something to do with how these components use rpc to call backend functions to update state? Working on a fix.

### Environment

```json
{
  "marimo": "0.9.4",
  "OS": "Darwin",
  "OS Version": "23.6.0",
  "Processor": "arm",
  "Python Version": "3.12.3",
  "Binaries": {
    "Browser": "129.0.6668.91",
    "Node": "v22.2.0"
  },
  "Dependencies": {
    "click": "8.1.3",
    "importlib-resources": "missing",
    "jedi": "0.19.1",
    "markdown": "3.7",
    "pygments": "2.18.0",
    "pymdown-extensions": "10.9",
    "ruff": "0.6.5",
    "starlette": "0.38.5",
    "tomlkit": "0.13.2",
    "typing-extensions": "4.12.2",
    "uvicorn": "0.30.6",
    "websockets": "12.0"
  },
  "Optional Dependencies": {
    "altair": "5.4.1",
    "anywidget": "0.9.13",
    "duckdb": "1.1.1",
    "ibis-framework": "9.5.0",
    "pandas": "2.2.2",
    "polars": "1.5.0",
    "pyarrow": "17.0.0"
  }
}
```

### Code to reproduce

```python
import marimo

__generated_with = "0.9.4"
app = marimo.App(width="medium")


@app.cell
def __():
    import marimo as mo
    from vega_datasets import data
    return data, mo


@app.cell
def __(data, mo):
    datasets = data.list_datasets()
    dropdown = mo.ui.dropdown(value="cars", options=datasets, full_width=True)
    dropdown
    return datasets, dropdown


@app.cell
def __(data, dropdown, mo):
    dataset = getattr(data, dropdown.value)
    mo.ui.table(
        dataset(), freeze_columns_left=["STATION"], freeze_columns_right=["DATE"]
    )
    return (dataset,)


@app.cell
def __(mo):
    b = mo.ui.file_browser(filetypes=[".txt", ".md"], selection_mode="directory")
    b
    return (b,)


if __name__ == "__main__":
    app.run()
```

ui.file_browser is not responsive on initial_path from ui.text
### Describe the bug

If I take the inital_path from ui.text() response, the file_browser is not reset to that new location. E.g. if I put another Drive letter to the text-input.

### Environment

marimo 0.9.9

### Code to reproduce


```
import marimo as mo
```

```
source = mo.ui.text()
source
```

```
mo.ui.file_browser(initial_path=source.value)
```

Hints from issue discussion:

Commit message: fix: Update FileBrowser state with arg changes

Patch to review:
diff --git a/frontend/src/components/data-table/data-table.tsx b/frontend/src/components/data-table/data-table.tsx
index 1c0953c6c23..985c18de023 100644
--- a/frontend/src/components/data-table/data-table.tsx
+++ b/frontend/src/components/data-table/data-table.tsx
@@ -4,6 +4,7 @@ import {
   type ColumnDef,
   type ColumnFiltersState,
   ColumnPinning,
+  type ColumnPinningState,
   getCoreRowModel,
   getFilteredRowModel,
   getPaginationRowModel,
@@ -19,7 +20,6 @@ import { Table } from "@/components/ui/table";
 import type { DownloadActionProps } from "./download-actions";
 import { cn } from "@/utils/cn";
 import { FilterPills } from "./filter-pills";
-import { useColumnPinning } from "./hooks/useColumnPinning";
 import { renderTableHeader, renderTableBody } from "./renderers";
 import { SearchBar } from "./SearchBar";
 import { TableActions } from "./TableActions";
@@ -54,8 +54,8 @@ interface DataTableProps<TData> extends Partial<DownloadActionProps> {
   onFiltersChange?: OnChangeFn<ColumnFiltersState>;
   reloading?: boolean;
   // Columns
-  freezeColumnsLeft?: string[];
-  freezeColumnsRight?: string[];
+  columnPinningState?: ColumnPinningState;
+  setColumnPinningState?: OnChangeFn<ColumnPinningState>;
 }
 
 const DataTableInternal = <TData,>({
@@ -82,16 +82,11 @@ const DataTableInternal = <TData,>({
   filters,
   onFiltersChange,
   reloading,
-  freezeColumnsLeft,
-  freezeColumnsRight,
+  columnPinningState,
+  setColumnPinningState,
 }: DataTableProps<TData>) => {
   const [isSearchEnabled, setIsSearchEnabled] = React.useState<boolean>(false);
 
-  const { columnPinning, setColumnPinning } = useColumnPinning(
-    freezeColumnsLeft,
-    freezeColumnsRight,
-  );
-
   const table = useReactTable<TData>({
     _features: [ColumnPinning, ColumnWrappingFeature, ColumnFormattingFeature],
     data,
@@ -139,9 +134,9 @@ const DataTableInternal = <TData,>({
           : // No pagination, show all rows
             { pagination: { pageIndex: 0, pageSize: data.length } }),
       rowSelection,
-      columnPinning: columnPinning,
+      columnPinning: columnPinningState,
     },
-    onColumnPinningChange: setColumnPinning,
+    onColumnPinningChange: setColumnPinningState,
   });
 
   return (
diff --git a/frontend/src/plugins/impl/DataTablePlugin.tsx b/frontend/src/plugins/impl/DataTablePlugin.tsx
index 31bb5dde23d..0c07ae74ae4 100644
--- a/frontend/src/plugins/impl/DataTablePlugin.tsx
+++ b/frontend/src/plugins/impl/DataTablePlugin.tsx
@@ -20,6 +20,7 @@ import type {
 } from "@/components/data-table/types";
 import type {
   ColumnFiltersState,
+  ColumnPinningState,
   OnChangeFn,
   PaginationState,
   RowSelectionState,
@@ -206,6 +207,9 @@ interface DataTableSearchProps {
   // Filters
   filters?: ColumnFiltersState;
   setFilters?: OnChangeFn<ColumnFiltersState>;
+  // Frozen columns
+  columnPinningState: ColumnPinningState;
+  setColumnPinningState: OnChangeFn<ColumnPinningState>;
 }
 
 export const LoadingDataTableComponent = memo(
@@ -223,6 +227,13 @@ export const LoadingDataTableComponent = memo(
     const [searchQuery, setSearchQuery] = useState<string>("");
     const [filters, setFilters] = useState<ColumnFiltersState>([]);
 
+    // Frozen columns state
+    const [columnPinningState, setColumnPinningState] =
+      useState<ColumnPinningState>({
+        left: props.freezeColumnsLeft,
+        right: props.freezeColumnsRight,
+      });
+
     // We need to clear the selection when sort, query, or filters change
     // Currently, our selection is index-based,
     // so we can't rely on the data to be the same
@@ -247,6 +258,14 @@ export const LoadingDataTableComponent = memo(
       setPaginationState((state) => ({ ...state, pageIndex: 0 }));
     }, [props.totalRows]);
 
+    // If frozen columns change, update frozen columns
+    useEffect(() => {
+      setColumnPinningState({
+        left: props.freezeColumnsLeft,
+        right: props.freezeColumnsRight,
+      });
+    }, [props.freezeColumnsLeft, props.freezeColumnsRight]);
+
     // Data loading
     const { data, loading, error } = useAsyncData<{
       rows: T[];
@@ -398,6 +417,8 @@ export const LoadingDataTableComponent = memo(
           totalRows={data?.totalRows ?? props.totalRows}
           paginationState={paginationState}
           setPaginationState={setPaginationState}
+          columnPinningState={columnPinningState}
+          setColumnPinningState={setColumnPinningState}
         />
       </>
     );
@@ -430,8 +451,8 @@ const DataTableComponent = ({
   filters,
   setFilters,
   reloading,
-  freezeColumnsLeft,
-  freezeColumnsRight,
+  columnPinningState,
+  setColumnPinningState,
 }: DataTableProps<unknown> &
   DataTableSearchProps & {
     data: unknown[];
@@ -531,8 +552,8 @@ const DataTableComponent = ({
             onFiltersChange={setFilters}
             reloading={reloading}
             onRowSelectionChange={handleRowSelectionChange}
-            freezeColumnsLeft={freezeColumnsLeft}
-            freezeColumnsRight={freezeColumnsRight}
+            columnPinningState={columnPinningState}
+            setColumnPinningState={setColumnPinningState}
           />
         </Labeled>
       </ColumnChartContext.Provider>
diff --git a/frontend/src/plugins/impl/FileBrowserPlugin.tsx b/frontend/src/plugins/impl/FileBrowserPlugin.tsx
index 0b3f86c2699..4afa92e5173 100644
--- a/frontend/src/plugins/impl/FileBrowserPlugin.tsx
+++ b/frontend/src/plugins/impl/FileBrowserPlugin.tsx
@@ -2,7 +2,7 @@
 import { z } from "zod";
 import { Table, TableCell, TableRow } from "@/components/ui/table";
 import { createPlugin } from "../core/builder";
-import { useState } from "react";
+import { useEffect, useState } from "react";
 import { useAsyncData } from "@/hooks/useAsyncData";
 import { rpc } from "../core/rpc";
 import { toast } from "@/components/ui/use-toast";
@@ -138,6 +138,25 @@ export const FileBrowser = ({
   const [path, setPath] = useState(initialPath);
   const [selectAllLabel, setSelectAllLabel] = useState("Select all");
   const [isUpdatingPath, setIsUpdatingPath] = useState(false);
+  const [isRestricted, setIsRestricted] = useState(restrictNavigation);
+  const [allowMultiSelect, setAllowMultiSelect] = useState(multiple);
+  const [selectMode, setSelectMode] = useState(selectionMode);
+
+  useEffect(() => {
+    setPath(initialPath);
+  }, [initialPath]);
+
+  useEffect(() => {
+    setIsRestricted(restrictNavigation);
+  }, [restrictNavigation]);
+
+  useEffect(() => {
+    setAllowMultiSelect(multiple);
+  }, [multiple]);
+
+  useEffect(() => {
+    setSelectMode(selectionMode);
+  }, [selectionMode]);
 
   const { data, loading, error } = useAsyncData(
     () =>
@@ -172,8 +191,8 @@ export const FileBrowser = ({
   const selectedFiles = value.map((x) => <li key={x.id}>{x.path}</li>);
 
   const canSelectDirectories =
-    selectionMode === "directory" || selectionMode === "all";
-  const canSelectFiles = selectionMode === "file" || selectionMode === "all";
+    selectMode === "directory" || selectMode === "all";
+  const canSelectFiles = selectMode === "file" || selectMode === "all";
 
   function setNewPath(newPath: string) {
     // Prevent updating path while updating
@@ -200,7 +219,7 @@ export const FileBrowser = ({
     // If restricting navigation, check if path is outside bounds
     const outsideInitialPath = newPath.length < initialPath.length;
 
-    if (restrictNavigation && outsideInitialPath) {
+    if (isRestricted && outsideInitialPath) {
       toast({
         title: "Access denied",
         description:
@@ -234,7 +253,7 @@ export const FileBrowser = ({
   function handleSelection(path: string, name: string, isDirectory: boolean) {
     const fileInfo = createFileInfo(path, name, isDirectory);
 
-    if (multiple) {
+    if (allowMultiSelect) {
       if (selectedPaths.has(path)) {
         setValue(value.filter((x) => x.path !== path));
         setSelectAllLabel("Select all");
@@ -370,7 +389,7 @@ export const FileBrowser = ({
     return `/${dirList.join(delimiter)}`;
   });
 
-  if (restrictNavigation) {
+  if (isRestricted) {
     parentDirectories = parentDirectories.filter((x) =>
       x.startsWith(initialPath),
     );
@@ -378,16 +397,16 @@ export const FileBrowser = ({
   parentDirectories.reverse();
 
   const selectionKindLabel =
-    selectionMode === "all"
+    selectMode === "all"
       ? PluralWords.of("file", "folder")
-      : selectionMode === "directory"
+      : selectMode === "directory"
         ? PluralWords.of("folder")
         : PluralWords.of("file");
   const renderHeader = () => {
     label = label ?? `Select ${selectionKindLabel.join(" and ", 2)}...`;
     const labelText = <Label>{renderHTML({ html: label })}</Label>;
 
-    if (multiple) {
+    if (allowMultiSelect) {
       return (
         <div className="grid grid-cols-2 items-center border-1">
           <div className="justify-self-start mb-1">{labelText}</div>


Code review comments:
diff_hunk: @@ -138,6 +138,25 @@ export const FileBrowser = ({
   const [path, setPath] = useState(initialPath);
   const [selectAllLabel, setSelectAllLabel] = useState("Select all");
   const [isUpdatingPath, setIsUpdatingPath] = useState(false);
+  const [isRestricted, setIsRestricted] = useState(restrictNavigation);
+  const [allowMultiSelect, setAllowMultiSelect] = useState(multiple);
+  const [selectMode, setSelectMode] = useState(selectionMode);
+
+  useEffect(() => {
path: frontend/src/plugins/impl/FileBrowserPlugin.tsx
text: @user1:
could we use `useControllableState`?
or could we write our own hook for this (preferred)? 

it would be nice to document these useEffects. the react team has started to discourage useEffects to trigger state changes, it might be better to do something like this (untested):

```ts
export const useInternalState<T>(initial: T) {
  const [state, setState] = useState(initial);
  const ref = useRef(initial);

  // Whenever the outside state changes, update the internal state
  if (ref.current !== initial) {
    setState(initial); // react is ok to setState during render
    ref.current = initial;
  }

  return [state, setState];
}
```

@user1:
i just pushed up these changes