DEV Community

Cover image for React devs KE 🇰🇪 Meetup February 2025 | Crafting Reusable Code with React & TypeScript: From Design to NPM
Dennis kinuthia
Dennis kinuthia

Posted on

React devs KE 🇰🇪 Meetup February 2025 | Crafting Reusable Code with React & TypeScript: From Design to NPM

slides used in the presentation

building the generic component

In this example we'll use a tale to demostrate how to build a generic component that adapts to any type of data

to go from a fully
hardcoded componenst to a
more dynamic one

we first put
the tables rows into an array and then pass that array to the component which we
loop over to get the table rows

And the same can also be done to
the columns

and now our table mponent can take in the data and the columns and then render
the table rows and columns

type Player = {
  id: number;
  name: string;
  age: number;
  rank: number;
}

type TableColumn = {
  label: string;
  accessor: keyof Player;
};
interface DynamicPlayerstableProps {
  columns: Array<TableColumn>;
  data: Array<Player>;
}

export function DynamicPlayerstable({data,columns}: DynamicPlayerstableProps) {
    return ....
}
Enter fullscreen mode Exit fullscreen mode

Our players table component

is now decoupled from the data but we can take this further by decouping it from
the shape of data. In it's current form it requires us to pass in data like this

const columns: Array<TableColumn> = [
  { label: "ID", accessor: "id" },
  { accessor: "name", label: "Name" },
  { accessor: "age", label: "Age" },
  { accessor: "rank", label: "Rank" },
];

const data: Array<Player> = [
  { id: 1, name: "Player1", age: 25, rank: "Silver" },
  { id: 2, name: "Player2", age: 30, rank: "Gold" },
  { id: 3, name: "Player3", age: 22, rank: "Bronze" },
  { id: 4, name: "Player4", age: 28, rank: "Platinum" },
  { id: 5, name: "Player5", age: 24, rank: "Diamond" },
  { id: 6, name: "Player6", age: 27, rank: "Silver" },
  { id: 7, name: "Player7", age: 29, rank: "Gold" },
];
return <DynamicPlayerstable columns={columns} data={data} />;
Enter fullscreen mode Exit fullscreen mode

The table structure is dynamic enough to accomodate any array of objects we pass
in with the only required field being od id because we use it as the key to
every row

<tbody>
  {data.map((player) => (
    <tr key={player.id}>
      {columns.map((column) => (
        <td key={column.accessor}>{player[column.accessor]}</td>
      ))}
    </tr>
  ))}
</tbody>
Enter fullscreen mode Exit fullscreen mode

To accomplish this we'll need to use a generic type usuaally maerked as T which is a way to pass in variables to out types and interfaces

firdt we pass it into the component

export function DynamicPlayerstable<T>({ data, columns }: DynamicPlayerstableProps);
Enter fullscreen mode Exit fullscreen mode

then we pass it into our parameter interface

interface DynamicPlayerstableProps<T> {
  columns: Array<TableColumn>;
  data: Array<Player>;
}

export function DynamicPlayerstable<T>({ data, columns }: DynamicPlayerstableProps<T>);
Enter fullscreen mode Exit fullscreen mode

this type T should be the type of the object that we pass in to the array so we can now replace type Player with T

interface DynamicPlayerstableProps<T> {
  columns: Array<TableColumn>;
  data: Array<T>;
}
Enter fullscreen mode Exit fullscreen mode

our TbaleColumn type was also relying on the type Player so the type is also going to change to TableColumn<T> so we can now replace type Player with T

At this point typescipt will be able to give us auto complete for the coluns field based on what type T is based on the array we pass into the data field

with player rows array
alt text

with teams row array
alt text

alt text
Out component is working fine from the outside but typescript is having a hader time understanding the types inside the component sine generice type T could be any type and it's keys could be numver|string|symbol , symbols aren't allowed as react keys so we can narrow that type by using an intersection & to inform typescrpt that the keys of T must be a string like we had in type Player

type TableColumn<T> = {
  label: string;
  accessor: keyof T & string;
};
interface DynamicPlayerstableProps<T> {
  columns: Array<TableColumn<T>>;
  data: Array<T>;
}
Enter fullscreen mode Exit fullscreen mode

That resolves that issue but introduces another one where typescipt doesn't know what type T is and what type T[keyof T] is going to resolve to

Because in it's current shape bothe

type Player = {
  id: number;
  name: string;
  age: number;
  rank: string;
};
//  and

type playeyWithArrays = {
  id: Array<number>;
  name: Array<string>;
  age: Array<number>;
  rank: Date;
  ratio: {
    numerator: number;
    denominator: number;
  };
};
Enter fullscreen mode Exit fullscreen mode

[!TIP]
any type can be passed into the component as type T , react and our table doesn't expect that and will throw an error if we try to render an array or Date object in a td or any react node . so to further specify what inputs we expect we can use the extends operator

[!NOTE]
in typescript it's either used to inherit behaviour from a class or to mark a generic type as a subtype of another more specific type

T extends string // can only accept strings
T extends {} // can only accept objects
T extends {id:number} // can only accept objects with a key `id` that is a number
T extends Record<string, string | number> // can only accept objects with string or number keys
Enter fullscreen mode Exit fullscreen mode

We'll use this to specify that only objects with string or number keys can be passed into the table

[!NOTE]
Record<TKey, TValue> can be used to specify objects

type TableColumn<T extends Record<string, string|number >> = {
  label: string;
  accessor: keyof T & string;
};
interface GenericTableProps<T extends Record<string, string|number >> {
  columns: Array<TableColumn<T>>;
  data: Array<T>;
}

export function GenericTable<T extends Record<string, string|number >>
Enter fullscreen mode Exit fullscreen mode

now with this we can only pass in objects that have string or number keys

const data = [
  {
    id: 1,
    name: "John Doe",
    age: 30,
    rank: "Gold",
  },
];
const baddata = [
  {
    id: 1,
    name: "John Doe",
    age: [30],
    rank: new Date(),
  },
];
Enter fullscreen mode Exit fullscreen mode

before restrictions
bad inputs without restriction
after restrictions
bad inputs with restriction

one last thing is rqeire type T to include a key id because it's going to be used as the key for the table rows

type GenericItem = Record<string, string | number> & { id: string }; // field of id:string required in passed in objects
type TableColumn<T extends GenericItem> = {
  label: string;
  accessor: keyof T & string;
};
interface GenericTableProps<T extends GenericItem> {
  columns: Array<TableColumn<T>>;
  data: Array<T>;
}

export function GenericPlayerstable<T extends GenericItem>({ data, columns }: GenericTableProps<T>);
Enter fullscreen mode Exit fullscreen mode

T-with-no-id

[!TIP]
Bonus tip : handling nested objects

const data = [
  {
    id: "1",
    name: "John Doe",
    age: 30,
    rank: "Gold",
    ratio: {
      numerator: 1,
      denominator: 2,
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

This type will cause issues because field ratio is an object and we can't render it directly in the table because react can't render objects as its children

First the Record type should also intersect with an object type

// now lets the value of the record to be an object
type GenericItem = Record<string, string | number | object> & { id: string };
type TableColumn<T extends GenericItem> = {
  label: string;
  accessor: PossibleNestedUnions<T, 10> & string;
};

interface GenericTableProps<T extends GenericItem> {
  title:string;
  description?:string;
  columns: Array<TableColumn<T>>;
  data: Array<T>;
}
Enter fullscreen mode Exit fullscreen mode

Then we add checks to handle object types

          <tbody>
            {data.map((row) => (
              <tr key={row.id}>
                {columns.map((column) => {
                  const value = row[column.accessor];
                  // this line grabs the nested object and maps it to a <td>
                  if (column.accessor.includes(".")) {
                    return <td key={column.accessor}>{getNestedProperty(row, column.accessor)}</td>;
                  }
                  //  we've already checked if the accessor is a nested object but typescript is not yet aware and 
                  // still thinks value is type string | number | objct at this point 
                  //  this part is only here to narrow the type or catch objects types that fell through
                  //  so that the value type on the section below is of type string | number
                  if (typeof value === "object") {
                    const nestedValue = getNestedProperty(row, column.accessor)
                    if(typeof nestedValue !== "string" || typeof nestedValue !== "number") {
                      // to avoid accidentally trying to renedr objects as react children
                      return <td key={column.accessor}>{JSON.stringify(nestedValue)}</td>;
                    }
                    return <td key={column.accessor}>{getNestedProperty(row, column.accessor)}</td>;
                  }
                  // value type is string| number , safe to render ina react td 
                  return <td key={column.accessor}>{value}</td>;
                })}
              </tr>
            ))}
          </tbody>
Enter fullscreen mode Exit fullscreen mode

getNestedProperty is a utility function to retrieve a nested property value from an object based on a dot-separated path

example

const obj = { a: { b: { c: 42 } } };
console.log(getNestedProperty(obj, "a"));   // Outputs: { b: { c: 42 } }
console.log(getNestedProperty(obj, "a.b.c")); // Outputs: 42
console.log(getNestedProperty(obj, "a.b.x")); // Outputs: undefined 
Enter fullscreen mode Exit fullscreen mode

so how do we get the types to the dot separated values? this one is non trivial and one of the examples of the ugly typescript people hate.
PossibleNestedUnions uses recursion to extract the dot separated keys to inputs of type nested object

example

type ExampleType = {
  a: string;
  b: {
    c: number;
    d: {
      e: boolean;
      f: {
        g: string;
      };
    };
  };
  h: Date;
};

type NestedKeys1 = PossibleNestedUnions<ExampleType, 1>;  // "a" | "b" | "h"
type NestedKeys2 = PossibleNestedUnions<ExampleType, 2>;  // "a" | "b" | "b.c" | "b.d" | "h"
type NestedKeys3 = PossibleNestedUnions<ExampleType, 3>;  // "a" | "b" | "b.c" | "b.d" | "b.d.e" | "b.d.f" | "h"
type NestedKeysAll = PossibleNestedUnions<ExampleType>;   // includes all nested paths
Enter fullscreen mode Exit fullscreen mode

and now our new type will be

import { PossibleNestedUnions } from "../types/nested_objects_union";
import { getNestedProperty } from "../utils/object";

type GenericItem = Record<string, string | number | object>&{id:string}
type TableColumn<T extends GenericItem> = {
  label: string;
  accessor: PossibleNestedUnions<T> & string;
};
interface GenericTableProps<T extends GenericItem> {
  columns: Array<TableColumn<T>>;
  data: Array<T>;
}
Enter fullscreen mode Exit fullscreen mode

with the array

   const gooddata = [
     {
       id: "1",
       name: "John Doe",
       age: 30,
       rank: "Gold",
       stats:{
        running:20,
        jumping:10,
        swimming:30,
        weapons:{
          stars:10,
          katana:5
        }
       }
     },
     {
       id: "2",
       name: "Pedro",
       age: 30,
       rank: "Gold",
       stats:{
        running:20,
        jumping:10,
        swimming:30,
        weapons:{
          stars:1,
          katana:4,
          blades:{
            short:10,
            long:5
          }
        }
       }
     },
   ];
Enter fullscreen mode Exit fullscreen mode

we'll get auto complete for all the possibly nested types
netsed-values-auto comlete

Finally we can make the title and description of the table dynamic too because we can render more than just players with this component

      <GenericTableWithNestedFields
      title="Players Table"
        description="list of players without their stats"
        data={gooddata}
        columns={[
          { accessor: "id", label: "age" },
          { accessor: "name", label: "name" },
          { accessor: "age", label: "age" },
          { accessor: "rank", label: "rank" },
        ]}
      />
      <GenericTableWithNestedFields
      title="Players Table with stats"
        description="list of players with their stats"
        data={gooddata}
        columns={[
          { accessor: "id", label: "age" },
          { accessor: "name", label: "name" },
          { accessor: "age", label: "age" },
          { accessor: "rank", label: "rank" },
          { accessor: "stats.running", label: "running" },
          { accessor: "stats.jumping", label: "jumping" },
          { accessor: "stats.swimming", label: "swimming" },
          { accessor: "stats.weapons.blades.short", label: "katana" },
        ]}
      />
Enter fullscreen mode Exit fullscreen mode

publishing to npm

Publishing to npm requires 3 things

  • a nodejs project
  • a package.json file . an npm account

in our case we also need

  • tsconfig for typescript
  • tsup for transpiling (we could use tsc for this too)

create a node project

npm init -y
npm install typescript --save-dev
tsc --init
Enter fullscreen mode Exit fullscreen mode

we'll change the tsconfig to look like this source + explanation

{
  "compilerOptions": {
    /* Base Options: */
    "esModuleInterop": true,
    "skipLibCheck": true,
    "target": "es2022",
    "allowJs": true,
    "resolveJsonModule": true,
    "moduleDetection": "force",
    "isolatedModules": true,
    "verbatimModuleSyntax": true,

    /* Strictness */
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,

    /* If transpiling with TypeScript: */
    // "module": "NodeNext",
    "outDir": "dist",
    // "sourceMap": true,

    /* AND if you're building for a library: */
    "declaration": true,

    /* AND if you're building for a library in a monorepo: */
    // "composite": true,
    // "declarationMap": true,

    /* If NOT transpiling with TypeScript: */
    "module": "preserve",
    "noEmit": true,
    /* If your code doesn't run in the DOM: */
    // "lib": ["es2022"]
    /* If your code runs in the DOM: */
    "lib": ["es2022", "dom", "dom.iterable"],
    // if you want to use JSX (react)
    "jsx":"react-jsx",

    "paths": {
      "@/*": ["./src/*"]
    },

  },
  // only files under `src` will be transpiled
  "include": ["src"],
  // exclude files under `dist` and `node_modules`
  "exclude": ["dist", "node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

Our file will be in the src directory with an index.ts file as the main entrypoint

[!NOTE]
Entrypoint is the file that is executed when the package is imported

example

// my-table-package is the name of the package which is the default entrypoint
import { GenericTable } from "my-table-package";
Enter fullscreen mode Exit fullscreen mode

entry points are defined in the package.json file

{
  "name": "my-table-package",
  "version": "1.0.0",
  "type": "module",
  // our transipiled file will be in the `dist` directory
  "files": [
    "dist"
  ],
  // our entrypoint is the `index.ts` file
  "main": "dist/index.js",
  // the exports field is a newer syntax for node 16+ that allows for multiple entrypoints
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    // we can also define exports for specific files
    "/styles":"./dist/index.css"
  },
  // peer dependencies are dependencies that we expect the user of the package to already have installed , if we're unsure that thy'll have it we put it i the dependencies field (it might lead to bigger final size)
    "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0",
    "tailwindcss": "^3.0.0 || ^4.0.0",
    "daisyui": " ^4.0.0 || ^5.0.0-beta"
  },
  // devDependencies are dependencies that are only used for development and don't get included in the final package
  // if uo have peer dependencies u can put them in dev dependencies
  "devDependencies": {
    "@arethetypeswrong/cli": "^0.17.3",
    "@changesets/cli": "^2.27.12",
    "@eslint/js": "^9.19.0",
    "@types/node": "^22.12.0",
    "@types/react": "^19.0.8",
    "daisyui": "5.0.0-beta.6",
    "eslint": "^9.19.0",
    "prettier": "^3.4.2",
    "react": "^19.0.0",
    "tailwindcss": "^4.0.1",
    "tsup": "^8.3.6",
    "typescript": "^5.7.3"
  }
}
Enter fullscreen mode Exit fullscreen mode

We'll build this package using tsup . we can use tsc (the typescript compiler) to build the package but that would require some changes

//tsconfig.json
{
   /* If transpiling with TypeScript: */
    "module": "NodeNext",
    "sourceMap": true,
}
Enter fullscreen mode Exit fullscreen mode

which requires that all our import should end with the file extension name

import { GenericTable } from "./src/components/GenericTable";
// to be 
import { GenericTable } from "./src/components/GenericTable.js";
Enter fullscreen mode Exit fullscreen mode

which can be a bit much if we already have the code we want to publish
to use tsup we just add a config

//tsup.config.ts
import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts","src/components/RedSquare.tsx"],
  splitting: false,
  format: ["cjs", "esm"],
  dts: true,
  clean: true,
  minify: false,
  platform: "browser", 
});

Enter fullscreen mode Exit fullscreen mode

and with our build script

{
  "scripts": {
    "dev": "tsup --watch",
    "build": "tsup",
    "format": "prettier --write .",
    "typecheck": "tsc",
    "lint": "eslint --quiet .",
    "ci:version": "changeset version",
    "ci:release": "changeset publish",
    "release": "changeset publish --no-git-checks",
    "check-exports": "attw --pack . --ignore-rules=cjs-resolves-to-esm --profile node16"
  }
}
Enter fullscreen mode Exit fullscreen mode
npm run build
Enter fullscreen mode Exit fullscreen mode

the outputs should be in the dist directory
and tio test if our component works (am using pnpm)
we check the working directory using pwd then head to the project we want to install the package in and run

 pnpm install <file-path-to-package>
Enter fullscreen mode Exit fullscreen mode

which adds a linked package like this

  "dependencies": {
    "@tailwindcss/vite": "^4.0.1",
    //locally linked package
    "my-table-package": "link:/home/dennis/Desktop/packaging/my-table-package",
    "pp": "^1.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "tailwindcss": "^4.0.1"
  }
Enter fullscreen mode Exit fullscreen mode

[!NOTE]
if u're not using pnpm u can use npm link instead of pnpm install

[!WARNING]
This will only work if you build yoour project locally where the package project also is , if you ad this to a projecct that get's build in CI or any other machnie it will not work

to make it usable by everyone we publish it to npm using

npm publish
Enter fullscreen mode Exit fullscreen mode

[!NOTE]
once at least one project installs your library it will be
undeletable from the npm registry

Extras

{
  "scripts": {
    "dev": "tsup --watch",
    "build": "tsup",
    "format": "prettier --write .",
    "typecheck": "tsc",
    "lint": "eslint --quiet .",
    "ci:version": "changeset version",
    "ci:release": "changeset publish",
    "release": "changeset publish --no-git-checks",
    "check-exports": "attw --pack . --ignore-rules=cjs-resolves-to-esm --profile node16"
  }
}
Enter fullscreen mode Exit fullscreen mode
npm run check-exports
Enter fullscreen mode Exit fullscreen mode
  • create a changelog
  • write unit tests
  • use other entrypoint , useful for

    • optimize and minimize the bundle size by allowing tree-shaking
    • separate and organize code into logical modules for better maintainability
    • enable independent development and testing of different components
    • facilitate conditional loading of features based on environment or user preferences
    • improve application startup time by loading only necessary code
    • enhance code reuse across different parts of the application or projects

example

// /src/inde: main entrypoint contents

import { GenericTable } from "./components/GenericTable";
import { GenericTableWithNestedFields } from "./components/GenericTableWithNestedFields";
import { HardCodedTable } from "./components/HardCodedTable";

export {
  GenericTable,
  GenericTableWithNestedFields,
  HardCodedTable
};
Enter fullscreen mode Exit fullscreen mode
import { defineConfig } from "tsup";

export default defineConfig({
  // our entrypoint is the `index.ts` file
  // RedSquare.tsx component will be build with it's own entrypoint
  entry: ["src/index.ts","src/components/RedSquare.tsx"],
  splitting: false,
  format: ["cjs", "esm"],
  dts: true,
  clean: true,
  minify: false,
  platform: "browser",

});

Enter fullscreen mode Exit fullscreen mode
{
      "exports": {
      // default entrypoint  
      ".": {
        "import": "./dist/index.js",
        "require": "./dist/index.cjs"
      },
      //  /redbox entrypoint
      "./redbox": "./dist/components/RedSquare.js"
    },
}
Enter fullscreen mode Exit fullscreen mode
// import the default entrypoint
import { GenericTable } from "my-table-package";
// import the redbox entrypoint
import { RedBox} from "my-table-package/redbox";
Enter fullscreen mode Exit fullscreen mode

The end

Top comments (0)