Creating TypeScript React Components with Parcel Bundler

Creating TypeScript React Components with Parcel Bundler

When we think of React, the word Components comes to mind, this library changed the way we built and integrated web elements while also allowing developers to use JavaScript power. Definition provided by the creators "A JavaScript library for building user interfaces", as the name suggests, React is a library instead of an ecosystem or framework.

React was released in 2013, and at the time, Angular was popularly used on web applications with MVC concepts, and it was acceptable to use Angular with Monolith applications, Django, ASP.NET, Spring, and others. Because it is very useful to forms and AJAX features to make your web application more dynamic. It's not hard to find a legacy Angular application 1x that demonstrates this popularity.

I must admit that I was an Angular fan; at the time, it was important for me to use every design pattern to organize the front-end context in order to avoid jQuery mess. Nothing new here, guys; things change.‌‌Every morning, new frameworks or libraries are released to solve problems and create new ones. It doesn't matter whether we use Angular, React, Vue, Ember, or Svelte; it is our responsibility to determine what works best for our project at the time.

Don't fall into the trap of thinking "should be necessary in the future." If you do, you'll end up building something like a Linux kernel to support an application form.

As I previously stated, React allows us to create components, so you can create an input form, autocomplete, select (server-side), file upload, newsletter form, and anything else reusable that HTML core does not currently support.

Let's take a look at this amazing library.

Components

Component could be a class or a function, and it could be stateless or stateful; we will discuss these concepts with a practical example.

Class Component

You should extends React.Component class, define a render function that returns a React.Element, and override any life cycles that are relevant for your implementation;

class Greetings extends React.Component {
  render() {
    return <p>Hello {this.props.name}</p>
  } 
}

Functional Component

Follow that contract a fn(props) -> React.Element, your function receives props as arguments and should return a React.Element;

function Grettings(props) { 
  return <p>Hello {props.name}</p>
} 

// or with magic (:

const Grettings = ({ name }) => <p>Hello {name}</p>

You can do most things in two ways, but keep in mind that Hooks cannot be used within a class component.

Stateless or Stateful

This term refers to the absence or presence of a state component.

  • Stateful, when you keeping some state in your component, we use setState + this.state when working with classes, and useState hook when working with functions.
  • Stateless, with no state and only props at your component;

Quick sample

After that, let's build a simple component using React concepts. This is an example of a React component that holds the click state and changes to random pictures from picsum.photos on each click. So simple!

To avoid complexity, I chose Parcel over Webpack to handle the bundle, all code is written in TypeScript, and important aspects are tested with Jest. As an extra bonus, I created a pipeline using GitHub actions.

To create reusable components Bundler may be useful to you, howerver If you want to create a React application, I recommend Next.js, this tool is very mature and includes extra features such as angular-cli (angular) or nuxt (vue).

Setup

Install and prepare the npm project.

npm init
npm install --save react react-dom
npm install --save-dev @parcel/transformer-typescript-tsc @parcel/validator-typescript @testing-library/jest-dom @testing-library/react @testing-library/user-event @types/jest @types/node @types/react @types/react-dom @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint http-server jest jest-environment-jsdom parcel parcel-resolver-tspaths process ts-jest ts-node typescript
  • package.json (partial)

Setup how to start the project, how to test, and how to check lint and pipeline integrations.

{
  "source": "src/index.html",
  "scripts": {
    "test": "jest",
    "start": "parcel",
    "build": "parcel build",
    "lint": "eslint ./src --ext .ts --ext .tsx",
    "check": "tsc --noEmit",
    "ci": "npm run build && npm run test && np, run lint && npm run check",
    "static": "npm run build && npm run static:serve",
    "static:serve": "http-server ./dist"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@parcel/transformer-typescript-tsc": "^2.8.3",
    "@parcel/validator-typescript": "^2.8.3",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/user-event": "^14.4.3",
    "@types/jest": "^29.2.6",
    "@types/node": "^18.11.18",
    "@types/react": "^18.0.27",
    "@types/react-dom": "^18.0.10",
    "@typescript-eslint/eslint-plugin": "^5.48.2",
    "@typescript-eslint/parser": "^5.48.2",
    "eslint": "^8.32.0",
    "http-server": "^14.1.1",
    "jest": "^29.3.1",
    "jest-environment-jsdom": "^29.3.1",
    "parcel": "^2.8.3",
    "parcel-resolver-tspaths": "^0.0.9",
    "process": "^0.11.10",
    "ts-jest": "^29.0.5",
    "ts-node": "^10.9.1",
    "typescript": "^4.9.4"
  }
}
  • .parcelrc
{
  "extends": "@parcel/config-default",
  "resolvers": [
    "...",
    "parcel-resolver-tspaths"
  ],
  "transformers": {
    "*.{ts,tsx}": [
      "@parcel/transformer-typescript-tsc"
    ]
  },
  "validators": {
    "*.{ts,tsx}": [
      "@parcel/validator-typescript"
    ]
  }
}

Index Component (src/index.tsx)

This is the application's entry point, where we tell React where to find its playground.

import * as React from 'react'
import { createRoot } from 'react-dom/client'
import { App } from '~/app'

const container = document.getElementById('root')

if (!container) throw 'Root container not found'

const root = createRoot(container)
root.render(<App />)

App Component (src/app.tsx)

A functional component that serves as a container and renders the RandomImage component, but if there are more, we can aggregate them in a DivElement or React.Fragment.

import React from 'react'
import { RandomImage } from './components/randomImage'

export function App() {
  return <RandomImage />
}

Index Page (src/index.html)

A static page that will be used to serve your React application.

<!doctype html>
<html>

<body>
    <div id="root"></div>
    <script type="module" src="./index.tsx"></script>
</body>

</html>

Button Component (src/components/button.tsx)

A stateless component that holds a click event with loading behavior while waiting for the image to load.

import React, { CSSProperties } from 'react'

export interface ButtonProps {
  label: string
  loading?: boolean
  style?: CSSProperties | undefined
  onClick: VoidFunction
}

export function Button(props: ButtonProps) {
  const { label, loading, style, onClick } = props
  const componentStyle = style ? { ...defaultStyle, ...style } : defaultStyle

  const buttonLabel = loading ? '🖐🏾...' : label

  return (
    <button style={componentStyle} type="button" onClick={onClick}>
      {buttonLabel}
    </button>
  )
}

const defaultStyle: React.CSSProperties = {
  display: 'inline-block',
  padding: '15px 25px',
  fontSize: '24px',
  cursor: 'pointer',
  textAlign: 'center',
  textDecoration: 'none',
  outline: 'none',
  color: '#fff',
  backgroundColor: '#212121',
  border: 'none',
  borderRadius: '15px',
}

Random Image Component (src/components/randomImage.tsx)

A stateful component that stores the image click and loading states. As the name implies, this responsibility includes displaying random images.‌

import React, { useCallback, useState } from 'react'
import { Button } from './button'

export function RandomImage() {
  const [count, setCount] = useState(0)
  const [loading, setLoading] = useState(false)

  const handleClick = useCallback(() => {
    if (loading) return

    setLoading(true)
    setCount(count + 1)
  }, [count, loading])

  const handleLoadImage = useCallback(() => {
    setLoading(false)
  }, [count, loading])

  const label = count == 0 ? 'Change Image' : `Change Image (${count})`

  return (
    <div style={styles.container}>
      <img
        style={styles.img}
        src={`https://picsum.photos/400?${count}`}
        onLoad={handleLoadImage}
      />
      <div>
        <title>Random Image Example</title>
        <Button
          style={styles.button}
          label={label}
          loading={loading}
          onClick={handleClick}
        />
      </div>
    </div>
  )
}

const styles = {
  button: {
    width: '100%',
  },
  img: {
    borderRadius: '5px 5px 0 0',
  },
  container: {
    border: '5px dotted #607D8B',
    margin: '5rem auto',
    width: '400px',
    padding: '1rem',
  },
}

Test setup

  • src/jestConfig.ts

You can use the JestConfigFile or include these options at package.json file.

import type {Config} from 'jest'

const config: Config = {
  verbose: false,
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  moduleFileExtensions: ['ts', 'tsx', 'js'],
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
  testMatch: ['**/__tests__/*.(test|spec).(ts|tsx|js)'],
}

export default config;
  • src/setupTests.tsx

It's useful for loading the libraries needed to run Jest tests.

import '@testing-library/jest-dom'
import '@testing-library/react/dont-cleanup-after-each'
  • src/components/__tests__/randomImage.spec.tsx

This test is responsible for covering all features such as image change and loading state.

import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React from 'react'
import { RandomImage } from '../components/randomImage'

describe('RandomImage', () => {
  it('should mount and change image', async () => {
    render(<RandomImage />)

    await userEvent.click(screen.getByText('Change Image'))

    expect(screen.getByRole('button')).toHaveTextContent('🖐🏾...')

    const image = screen.getByRole('img')
    fireEvent.load(image)

    waitFor(() => screen.getByRole('img'))

    expect(screen.getByRole('button')).toHaveTextContent('Change Image (1)')
  })
})

Bundle files

  • tsconfig.json

The most difficult aspect of using typescript for me is having to deal with these settings on every project.

{
    "compilerOptions": {
      "target": "ES6",
      "moduleResolution": "node",
      "esModuleInterop": true,
      "strict": true,
      "jsx": "react",
      "baseUrl": "./src",
      "paths": {
        "~/*": ["./*"]
      },
      "types": ["node", "jest"],
      "typeRoots": ["node_modules/@types", "@types"]
    },
    "rootDir": "./src",
    "incremental": true,
    "experimentalDecorators": true,
    "lib": ["dom", "es2015"]
  } 

Run application locally

To see the application running at http://localhost:1234, type the command below.

npm start
> [email protected] start
> parcel

Server running at http://localhost:1234

Building application

npm run build

Application execution

npm run static

Lint & Check

npm run lint
npm run check

Live Example

Thank you for providing a static server for us, Surge.

Check out this live and working example

Final thoughts

If you prefer, you can clone the repository.

I made a list to look into at the repository features.

  • parcel with typescript;
  • prettier code formatter;
  • eslint to check typescript issues;
  • jest tests with typescript;
  • github actions configured;
  • script to publish using surge CLI;

Keep your kernel up to date, and see you later.

References