Maneuver #1: Customize Expandable Tables in Ant Design — Controlled vs. Uncontrolled Approaches

Technology covers in this story:

  • Next.js

  • Typescript

  • Tailwind

  • Antd Components

Demo of the end result is here.


Intro: Why Maneuver?

as a Frontend Engineer you might frequently stumble upon the UI/UX design that doesn’t match some components even though the designers team already agreed to use certain design’s components (typical designers with some idealism, I’ve been there before :D). well here we are as developers, we often need to get creative when the design doesn’t fit the library defaults. That’s where the ‘maneuver’ comes in.

Here I share some of my experience for being maneuver from design agreements with Designer’s team. This example uses Next.js with TypeScript and AntD components. buckle up, we try something fun in here that you might never encountered before. I create this as practical as I could so you can get along with me for the real experience.

Setup

anyway, if you find yourself implusively directly want to code then I prepared the source code for this tutorial in here.

if you enjoy creating this on your own then bare with me, we’ll do that together.

start with installing next.js

npx create-next-app@latest

and this is my configuration settings of next.js (this is what I preferred, you can adjust it by yours)

✔ What is your project named? … maneuver_1_expandable
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
✔ What import alias would you like configured? … @/*

next install the antd components.

npm install antd

my package.json’s dependencies.

 "dependencies": {
    "antd": "^5.26.7",
    "next": "15.4.6",
    "react": "19.1.0",
    "react-dom": "19.1.0"
  },

thats it all the dependencies needed for this tutorial. if you wondering why you didn’t see “tailwind” as dependencies is because it bundles inside the next module.

the dependencies itself is worth noting. as you see here I’m using Antd components with Version 5.25.X, this is important because in this tutorial I will use the features that only available in this version like Semantic DOM (this is my favourite because you can directly style using native css instead of tweak it via separated css files or global css file), you can see the detail of changelog Antd in here.

next, I will create some adjustment in the next.js app I just generated

in page.tsx, clear all default generated code and leave it like this:

export default function Home() {
  return (
    <div className="flex items-center justify-center h-[100vh]">
      {/* Code here */}
      <h1>Code Here</h1>
    </div>
  );
}

and tweak for globals.css like this:

@import "tailwindcss";

:root {
  margin: 0;
  padding: 0;
}

run it with npm run dev and it will look like this:

it should good to go.


PART I

Step 1: Base Table

let’s start to create the base of Table. in this tutorial I take of granted from Antd Documentation and tweak it a little the data in the table for the sake of easiness for you to follow.

page.tsx

interface DataType {
  key: React.Key;
  name: string;
  age: number;
  address: string;
  description: string;
  phone_number: string;
  occupation: string;
  status: "single" | "married";
}

const data: DataType[] = [
  {
    key: 1,
    name: "John Brown",
    age: 32,
    address: "New York No. 1 Lake Park",
    description:
      "My name is John Brown, I am 32 years old, living in New York No. 1 Lake Park. ",
    occupation: "Software Engineer",
    phone_number: "8888888888",
    status: "married",
  },
  {
    key: 2,
    name: "John Smith",
    age: 32,
    address: "New York No. 1 Lake Park",
    description:
      "My name is John Smith, I am 27 years old, living in New York No. 1 Lake Park. ",
    occupation: "Network Engineer",
    phone_number: "99999999",
    status: "single",
  },
  {
    key: 3,
    name: "Ahmed Alakhtar",
    age: 32,
    address: "New York No. 1 Lake Park",
    description:
      "My name is Ahmed Alakhtar, I am 29 years old, living in New York No. 1 Lake Park. ",
    occupation: "AI engineer",
    phone_number: "111111111",
    status: "single",
  },
];

const columns: TableColumnsType<DataType> = [
  {
    title: "Name",
    dataIndex: "name",
    key: "name",
  },
  { title: "Age", dataIndex: "age", key: "age" },
  { title: "Address", dataIndex: "address", key: "address" },
  { title: "Descritpion", dataIndex: "description", key: "description" },
];


export default function Home() {
  return (
    <div className="flex items-center justify-center h-[100vh]">
      {/* Code here */}
      <Table<DataType> columns={columns} dataSource={data} pagination={false} />
    </div>
  );
}

Notice that I tweaked some from the antd documentation.

In DataType I add three more key value pairs in interface.

interface DataType {
  key: React.Key;
  name: string;
  age: number;
  address: string;
  description: string;
  phone_number: string; // new
  occupation: string; // new 
  status: "single" | "married"; // new
}

create data for data source table as you please. what’s important is you have to comply with the columns you made.

Inside the component, the Table component I’m not implementing the expandable yet, we’ll discuss more later but for the sake of convenient I remove the pagination (we not using that here).

so the result would be like this:

Step 2: Expandable Basics

currently there’s no special about this, we just create the very base of the table Utilizing Antd Components. then creating the expandable is easy, you just add it like the documentation and boom the expandable is applicable.

   <Table<DataType>
        columns={columns}
        dataSource={data}
        pagination={false}
        expandable={{
          expandedRowRender: (record) => (
            <p style={{ margin: 0 }}>{record.description}</p>
          ),
        }}
      />

the result is like this.

Note: In case you got an error is very likely that because the next.js assume you are code in server-side, so in order to make the next.js know that you are code in client-side just add “use client” at the very top of the code.

"use client";

well in reality is not that easy, leaving the table like this would piss the designers off :D so then this is what I called Maneuver, I have to tweak it so it meets the Designers and the Product Managers Standart.

First off, we have to change the toggler from the first column to the last column and change it to text “details” rather than icon.

Step 3: Custom Trigger

To change the toggler column position you can Add it like this.

const columns: TableColumnsType<DataType> = [
  {
    title: "Name",
    dataIndex: "name",
    key: "name",
  },
  { title: "Age", dataIndex: "age", key: "age" },
  { title: "Address", dataIndex: "address", key: "address" },
  { title: "Descritpion", dataIndex: "description", key: "description" },
  Table.EXPAND_COLUMN, // add this code
];

this will make sure that the toggler on the last column.

the result would be like this.

its halfway done, now let’s change the UI look of the triggerer. here we have to utilize the expandIcon API.

in the age of AI and LLM we developer often overlook the greatness of documentation, so we have to encourage ourselves to always look into documentation because it’s more Accurate and up to date.

the expandIcon API in the documentation is like this.

what does it mean though? well if we don’t understand we can log with console.logto see what’s going on in there.

<Table<DataType>
        columns={columns}
        dataSource={data}
        pagination={false}
        expandable={{
          expandedRowRender: (record) => (
            <p style={{ margin: 0 }}>{record.description}</p>
          ),
          expandIcon: (props) => {
            console.log("props: ", props);
          },
        }}
      />

after this, open the developer console and see what’s come when you click the triggerer?

There you go, now we know what’s going on. meaning inside props there are 5 properties. you don’t have to use all those 5 properties, the most useful properties would be expanded (this is for detecting if the row is on expand state or not), onExpand (function from antd that make the toggle expandable works), and lastly record (this held all the data that we define in DataType interface). so we will utilize those three, we can destructuring those properties like this.

expandIcon: ({ expanded, onExpand, record }) => {
            return expanded ? (
              <p onClick={(e) => onExpand(record, e)}>Hide</p>
            ) : (
              <p onClick={(e) => onExpand(record, e)}>Details</p>
            );
          },

and the result would be like this.

Triggerer UI Created Perferctly.

Step 4: Custom Expand Content

now for the last touch, we have to change the UI of expanded row.

the key lies in this code.

  expandedRowRender: (record) => (
            <p style={{ margin: 0 }}>{record.description}</p>
          ),

again, you may seem unfamiliar with it then we have to log it in order to knew what’s going on.

expandedRowRender: (record) => {
            console.log("record: ", record);
            return <p style={{ margin: 0 }}>{record.description}</p>;
          },

when you see at developer console its appear like this.

the record was the value from DataType that we have defined before. so we can utilize it for creating the UI like this.

export default function Home() {
  const expandableUIRow: (record: DataType) => JSX.Element = (record) => {
    return (
      <section className="grid gap-8 p-2 grid-cols-2 max-w-[1000px]">
        <div>
          <p>
            <span className="font-bold">Name: </span> {record.name}
          </p>
          <p>
            <span className="font-bold">Age: </span> {record.age}
          </p>
          <p>
            <span className="font-bold">Address: </span> {record.address}
          </p>
          <p>
            <span className="font-bold">Description: </span>{" "}
            {record.description}
          </p>
        </div>

        <div>
          <p>
            <span className="font-bold">Occupation: </span> {record.occupation}
          </p>
          <p>
            <span className="font-bold">Phone: </span> {record.phone_number}
          </p>
          <p>
            <span className="font-bold">Status: </span> {record.status}
          </p>
        </div>
      </section>
    );
  };

  return (
    <div className="flex items-center justify-center h-[100vh]">
      {/* Code here */}
      <Table<DataType>
        columns={columns}
        dataSource={data}
        pagination={false}
        expandable={{
          expandedRowRender: (record) => {
            return <div>{expandableUIRow(record)}</div>;
          },
          expandIcon: ({ expanded, onExpand, record }) => {
            return expanded ? (
              <p onClick={(e) => onExpand(record, e)}>Hide</p>
            ) : (
              <p onClick={(e) => onExpand(record, e)}>Details</p>
            );
          },
        }}
      />
    </div>
  );
}

and the result would be like this.

There you go! now the result is as expected!

This is how to tweak expandable Table using Antd Components. this Part is completely rely on Antd default configuration, what does it mean anyway? well all the tutorial I show you above we just changed the UI from the necessary part and leave the functionality to antd, meaning you don’t necessarily control over the functionality

you might asked, then how to control over the functionality? we’ll do that in the Part II below, but before we jump in to the “How” you need to know “Why” First, Right?

Why would we need to control over functionality, is it important?

well the answer is it DEPENDS. if you want to just showing the table that looks like above then you most likely don’t need to control over. IT IS important when it comes to rendering base on Fetching backend data through API, and I belive that 80% of Table usage its using Dynamic Data Meaning we have to deal with Fetch API and so on.

as simple like this, what if I want the expandable triggerer is only appear when the data from API is on set? what if I want to Fetch detail API each one I click the “details” triggerer? what if I want to add shimmer loading when the data is not set? and so on.

if you already have reasons then let’s jump in.


PART II

Step 5: Full Control with State

this section will be focus on tweaking more depth. the table end result would be the same as above so I will use the same file like above and place the table below the existing table. adjust the existing code like this.

page.tsx

<div className="flex items-center justify-center h-[100vh]">
      {/* Code here */}
      <div>
        <Table<DataType>
          columns={columns}
          dataSource={data}
          pagination={false}
          expandable={{
            expandedRowRender: (record) => {
              console.log("record: ", record);
              return <div>{expandableUIRow(record)}</div>;
            },
            expandIcon: ({ expanded, onExpand, record }) => {
              return expanded ? (
                <p onClick={(e) => onExpand(record, e)}>Hide</p>
              ) : (
                <p onClick={(e) => onExpand(record, e)}>Details</p>
              );
            },
          }}
        />

        {/* Code here PART II */}
        <div className="mt-6">

        </div>
      </div>
    </div>

I will use the same Data type and the data same as before, the main differences is create another columns configuration for this new table and some adjustment in Data type and the data itself. the better way without ruining the last table is copy and paste it with different names.

interface DataTypeNew {
  key: string; // change the key type to string
  name: string;
  age: number;
  address: string;
  description: string;
  phone_number: string;
  occupation: string;
  status: "single" | "married";
}

const datanew: DataTypeNew[] = [
  {
    key: "1", // change to string
    name: "John Brown",
    age: 32,
    address: "New York No. 1 Lake Park",
    description:
      "My name is John Brown, I am 32 years old, living in New York No. 1 Lake Park. ",
    occupation: "Software Engineer",
    phone_number: "8888888888",
    status: "married",
  },
  {
    key: "2", // change to string
    name: "John Smith",
    age: 32,
    address: "New York No. 1 Lake Park",
    description:
      "My name is John Smith, I am 27 years old, living in New York No. 1 Lake Park. ",
    occupation: "Network Engineer",
    phone_number: "99999999",
    status: "single",
  },
  {
    key: "3", // change to string
    name: "Ahmed Alakhtar",
    age: 32,
    address: "New York No. 1 Lake Park",
    description:
      "My name is Ahmed Alakhtar, I am 29 years old, living in New York No. 1 Lake Park. ",
    occupation: "AI engineer",
    phone_number: "111111111",
    status: "single",
  },
];

const columnsNewTable: TableColumnsType<DataTypeNew> = [
  {
    title: "Name",
    dataIndex: "name",
    key: "name",
  },
  { title: "Age", dataIndex: "age", key: "age" },
  { title: "Address", dataIndex: "address", key: "address" },
  { title: "Descritpion", dataIndex: "description", key: "description" },
];

.... another code

<Table<DataTypeNew>
      columns={columnsNewTable}
      dataSource={datanew}
      pagination={false}
/>

Notice that in columnsNewTable I remove the Table.EXPAND_COLUMN. this time make sure the columnsNewTable is INSIDE the function.

then te result would be like this.

okay the base table is created successfully, we now pose the real challenge. what’s to do next? firstly, I will create the new column called “action” and place the triggererinside this column.

const columnsNewTable: TableColumnsType<DataTypeNew> = [
    {
      title: "Name",
      dataIndex: "name",
      key: "name",
    },
    { title: "Age", dataIndex: "age", key: "age" },
    { title: "Address", dataIndex: "address", key: "address" },
    { title: "Descritpion", dataIndex: "description", key: "description" },

    // Start of: new code
    {
      title: "Action",
      key: "action",
      render(value, record, index) {
        return (
          <div className="flex items-center cursor-pointer ">
            <p> Details </p>
          </div>
        );
      },
    },
   // End of: new code
  ];

with this in place, it still doesn’t have the triggerer functionality but it does showing the action column with each has “Details” UI inside of it.

to create it’s functionality back to the table and we will utilize the onExpand and expandedRowKeys API.

adjust the table component like this.

 <Table<DataTypeNew>
            columns={columnsNewTable}
            dataSource={datanew}
            pagination={false}
            expandable={{
              expandedRowRender: (record) => {
                return <div>{expandableUIRow(record)}</div>;
              },
              onExpand: (expanded, record) => {
                // code the logic in here

              }
            }}
          />

because onExpand itself is a function that returns nothing, we have to create the logic inside those function. to create the functionality we create new function and pass those two parameters (expanded and record) to another function we should create utlizing the useState.

  const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);

  const handleExpand: ({
    expanded,
    record,
  }: {
    record: DataTypeNew;
    expanded?: boolean;
  }) => void = ({ expanded, record }) => {

    setExpandedRowKeys((prev) => {
      return prev.includes(record.key)
        ? prev.filter((key) => key !== record.key)
        : [...prev, record.key];
    });

  };

if you curious what does this function do, it simply means that if some row is expanded then the key of that row will be stored in expandedRowKeys leading to expanded version of table. for example if we refer to our data before.

const datanew: DataTypeNew[] = [
  {
    key: "1", // notice in this
    name: "John Brown",
    age: 32,
    address: "New York No. 1 Lake Park",
    description:
      "My name is John Brown, I am 32 years old, living in New York No. 1 Lake Park. ",
    occupation: "Software Engineer",
    phone_number: "8888888888",
    status: "married",
  },
  {
    key: "2", // notice in this
    name: "John Smith",
    age: 32,
    address: "New York No. 1 Lake Park",
    description:
      "My name is John Smith, I am 27 years old, living in New York No. 1 Lake Park. ",
    occupation: "Network Engineer",
    phone_number: "99999999",
    status: "single",
  },
  {
    key: "3", // notice in this
    name: "Ahmed Alakhtar",
    age: 32,
    address: "New York No. 1 Lake Park",
    description:
      "My name is Ahmed Alakhtar, I am 29 years old, living in New York No. 1 Lake Park. ",
    occupation: "AI engineer",
    phone_number: "111111111",
    status: "single",
  },
];

suppose you want to expand the John smith then the expandedRowKeys would be

["2"]

if you expand another table again suppose Ahmed Alakhtar then it would like this:

["2", "3"]

and so on.

after we create the logic function above we have to intregate those function inside Table components like this

 <Table<DataTypeNew>
            columns={columnsNewTable}
            dataSource={datanew}
            pagination={false}
            expandable={{
              expandedRowRender: (record) => {
                return <div>{expandableUIRow(record)}</div>;
              },
              expandedRowKeys: expandedRowKeys, // add this
              showExpandColumn: false, // add this
              onExpand: (expanded, record) => {
                // code the logic in here
                handleExpand({ expanded, record }); // add this
              },
            }}
          />

make sure showExpandColumn is false so it didn’t show the default triggerer from Antd.

after this don’t forget to implement it to our Table Column like this

  const columnsNewTable: TableColumnsType<DataTypeNew> = [
    {
      title: "Name",
      dataIndex: "name",
      key: "name",
    },
    { title: "Age", dataIndex: "age", key: "age" },
    { title: "Address", dataIndex: "address", key: "address" },
    { title: "Descritpion", dataIndex: "description", key: "description" },
    {
      title: "Action",
      key: "action",
      render(value, record, index) {
        return (
          <div
            className="flex items-center cursor-pointer "
            onClick={() => handleExpand({ record })} // add this code
          >
            <p> Details </p>
          </div>
        );
      },
    },
  ];

with all this set up the result should be as expected. show below

this is what I meant about controlling over. this give you flexibility and customabilityabout your function, of course it comes with the price with extra steps and customization. all the control over lies in this code

 const handleExpand: ({
    expanded,
    record,
  }: {
    record: DataTypeNew;
    expanded?: boolean;
  }) => void = ({ expanded, record }) => {

    setExpandedRowKeys((prev) => {
      return prev.includes(record.key)
        ? prev.filter((key) => key !== record.key)
        : [...prev, record.key];
    });

  };

you can literally do everything you need in above function. need example?

let’s see the example below.

Step 6: Dynamic Data Example

okay let’s try this. for example I want to make sure each row that I clicked is fetch the detail about its data from API like I mentioned before. you can do like this

const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
  const [isTriggerFetchDataFromAPI, setIsTriggerFetchDataFromAPI] =
    useState<boolean>(false); // add this
  const [keyEachRow, setKeyEachRow] = useState<string>(""); // add this

  const handleExpand: ({
    expanded,
    record,
  }: {
    record: DataTypeNew;
    expanded?: boolean;
  }) => void = ({ expanded, record }) => {
    setKeyEachRow(record.key); // add this

    // Start of: add this
    if (expandedRowKeys.includes(record.key)) { 
      setIsTriggerFetchDataFromAPI(false);
    } else {
      setIsTriggerFetchDataFromAPI(true);
    }
    // End of: add this

    setExpandedRowKeys((prev) => {
      return prev.includes(record.key)
        ? prev.filter((key) => key !== record.key)
        : [...prev, record.key];
    });
  };

because I don’t have any Rest API calls so I simulate it with shimmer loading so the result as if fetching dinamycally. still in the page.tsx create like this

const [loadingEachRows, setLoadingEachRows] = useState<{
    [key: string]: boolean;
  }>({});

  const FetchAPI = useCallback(() => {
    setLoadingEachRows((prev) => ({
      ...prev,
      [keyEachRow]: true,
    }));

    setTimeout(() => {
      setLoadingEachRows((prev) => ({
        ...prev,
        [keyEachRow]: false,
      }));
    }, 3000);
  }, [keyEachRow]);

  useEffect(() => {
    if (isTriggerFetchDataFromAPI) {
      FetchAPI();
      setIsTriggerFetchDataFromAPI(false);
    }
  }, [isTriggerFetchDataFromAPI, FetchAPI]);

don’t forget you need to change the respond expandableHTML too, since I don’t want to break it with the first table so I create new Expandable UI base on expandableHTML like this

const expandableUIRowNew: (record: DataTypeNew) => JSX.Element = (record) => {
    const isLoading = loadingEachRows[record.key] || false;

    return (
      <section className="grid gap-8 p-2 grid-cols-2 max-w-[1000px]">
        <div>
          <div>
            <span className="font-bold">Name: </span>{" "}
            {isLoading ? (
              <Skeleton.Button
                active
                style={{ maxHeight: 20, minWidth: 100 }}
              />
            ) : (
              record.name
            )}
          </div>

          <div>
            <span className="font-bold">Age: </span>{" "}
            {isLoading ? (
              <Skeleton.Button
                active
                style={{ maxHeight: 20, minWidth: 100 }}
              />
            ) : (
              record.age
            )}
          </div>
          <div>
            <span className="font-bold">Address: </span>{" "}
            {isLoading ? (
              <Skeleton.Button
                active
                style={{ maxHeight: 20, minWidth: 200 }}
              />
            ) : (
              record.address
            )}
          </div>
          <div>
            <span className="font-bold">Description: </span>{" "}
            {isLoading ? (
              <Skeleton.Button
                active
                style={{ maxHeight: 20, minWidth: 250 }}
              />
            ) : (
              record.description
            )}
          </div>
        </div>

        <div>
          <div>
            <span className="font-bold">Occupation: </span>{" "}
            {isLoading ? (
              <Skeleton.Button
                active
                style={{ maxHeight: 20, minWidth: 100 }}
              />
            ) : (
              record.occupation
            )}
          </div>
          <div>
            <span className="font-bold">Phone: </span>{" "}
            {isLoading ? (
              <Skeleton.Button
                active
                style={{ maxHeight: 20, minWidth: 100 }}
              />
            ) : (
              record.phone_number
            )}
          </div>
          <div>
            <span className="font-bold">Status: </span>{" "}
            {isLoading ? (
              <Skeleton.Button
                active
                style={{ maxHeight: 20, minWidth: 100 }}
              />
            ) : (
              record.status
            )}
          </div>
        </div>
      </section>
    );
  };

here I’m utilizing the Skeleton Component from antd to create shimmer loading. next don’t forget change the table too like this

<Table<DataTypeNew>
            columns={columnsNewTable}
            dataSource={datanew}
            pagination={false}
            expandable={{
              expandedRowRender: (record) => {
                return <div>{expandableUIRowNew(record)}</div>; // change this 
              },
              expandedRowKeys: expandedRowKeys,
              showExpandColumn: false,
              onExpand: (expanded, record) => {
                // code the logic in here
                handleExpand({ expanded, record });
              },
            }}
          />

its all sets, the result should be as expected like below

Pretty Cool Right!. That’s it from this Tutorial, I hope you will get better understanding about the Table and expandable thing.

I build production apps using React, Next.js, and Ant Design, and over time I’ve collected many practical solutions to common UI challenges. The ‘Maneuver’ series shares these insights, tested in real projects, to help other developers avoid reinventing the wheel. I Hope you can get benefit from it.


Key takeaway.

It really comes down to how much control you need. If the out-of-the-box behavior already fits your requirements, don’t over-engineer it. But when the default options fall short, that’s the time to take full control of your Table component.


  • Repository for this Tutorial in here.

  • Demo for this Tutorial in here.

See you ou on the next “Maneuver” part :D

0
Subscribe to my newsletter

Read articles from Achmad Muqorrobin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Achmad Muqorrobin
Achmad Muqorrobin

Software Engineer, Cybersecurity practitioner and Machine Learning enthusiast.