DEV Community

Cover image for SolidJS Crash Course - Building a REST API Client - Part 1
Muhammad Ahsan Ayaz
Muhammad Ahsan Ayaz

Posted on

SolidJS Crash Course - Building a REST API Client - Part 1

Introduction

While React, Angular and VueJS are already famous within the Web Development community. SolidJS is the new cool framework in town. And everyone is hyped about it. And in this series of articles, you’re gonna learn the ins and outs of Solid JS and its reactivity. There are lot of concepts to cover, so buckle up!

If you'd rather want to watch a video, I've got you 🙌

Setting up the project

We're going to use pnpm to install the packages. You can use npm or yarn as well. You can install pnpm by running npm install -g pnpm in your terminal.

Creating and Serving

npx degit solidjs/templates/ts solid-rest-client-app
cd solid-rest-client-app
pnpm i # or `npm install` or `yarn`
pnpm run dev # Open the app on localhost:3000
# OR
# npm run dev
# yarn dev
Enter fullscreen mode Exit fullscreen mode

Once you have run the last command, you should be able to see the app running at http://localhost:3000

Adding Tailwind CSS

Follow the instructions from SolidJS Official Docs OR from the instructions below.

Install the packages using the following command:

pnpm add --save-dev tailwindcss postcss autoprefixer
Enter fullscreen mode Exit fullscreen mode

Then run the following command to generate the tailwind config

npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

Replace the content property to target the SolidJS files in the tailwind.config.js file:

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
Enter fullscreen mode Exit fullscreen mode

Now add the following CSS in the src/index.css file:

@tailwind base;
@tailwind components;
@tailwind utilities;

/* Existing CSS Code */
Enter fullscreen mode Exit fullscreen mode

Add Nunito Font

Add the Nunito font in the index.html inside the <head></head> tags:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet">
Enter fullscreen mode Exit fullscreen mode

Finally, add the Nunito font to the index.css file as follows:

@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  margin: 0;
  font-family: 'Nunito', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

code {
  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
    monospace;
}

Enter fullscreen mode Exit fullscreen mode

Add Ionicons

Add the Ionic Fonts (Ionicons) in the index.html inside the <head></head> tags as follows:

<script type="module" src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js"></script>
<script nomodule src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.js"></script>
Enter fullscreen mode Exit fullscreen mode

Let's try using an icon now. Update the App.tsx file to add an Ionicon as follows:

const App: Component = () => {
  return (
    <div class={styles.App}>
      <header class={styles.header}>
        <img src={logo} class={styles.logo} alt="logo" />
        <p>
          Edit <code>src/App.tsx</code> and save to reload.
        </p>
        <a
          class={styles.link}
          href="https://github.com/solidjs/solid"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn Solid
        </a>
        <ion-icon name="accessibility-outline"></ion-icon>
      </header>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

You'll notice that TypeScript isn't happy at all at this moment and may show something like this:

IonIcons making TS Sad

This is because SolidJS and TypeScript together do not understand what an ‘ion-icon’ element is. Because it is being used in JSX. Let's fix it by providing the types. Create a new folder named types inside the src folder. And a new file inside named solid-js.d.ts. Then add the following code inside:

import "solid-js";

declare module "solid-js" {
  namespace JSX {
    interface IntrinsicElements {
      "ion-icon": any;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

After this, you will see that the App.tsx and TypeScript are happy now. Look at the app and you should be able to see the accessibility icon on the view.

Add ESLint

Coding is great. But writing good code is better. We'll add ESlint to our project to make sure our code stays great throughout the development of the app. Sorry the Software Architect inside me can't refrain from enforcing standards 😄!

Run the following command from the terminal to set up ESLint:

npx eslint --init
Enter fullscreen mode Exit fullscreen mode

Select the following options when presented throughout the installation:

  • To check syntax, find problems, and enforce code style
  • JavaScript modules (import/export)
  • None of these (for Framework)
  • Yes (for "Does your project use TypeScript")
  • Browser (for "Where does your code run")
  • Standard: https://github.com/standard/eslint-config-standard-with-typescript (for "Which style guide do you want to follow")
  • JavaScript (for "What format do you want your config file to be in")
  • JSON for the type of file to use
  • pnpm to install the packages

Then run the following to install the required packages:

pnpm add --save-dev eslint eslint-plugin-solid @typescript-eslint/parser 
Enter fullscreen mode Exit fullscreen mode

Once done, update the .eslintrc file to use the following code:

{
  "env": {
    "browser": true,
    "es2021": true
  },
  "overrides": [],
  "parserOptions": {
    "ecmaVersion": "latest",
    "sourceType": "module"
  },
  "rules": {},
  "parser": "@typescript-eslint/parser",
  "plugins": ["solid"],
  "extends": [
    "eslint:recommended",
    "standard-with-typescript",
    "plugin:solid/typescript"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Navigation and Routing

We'll start creating some pages and will set up routing. To work with routes, we will install the solid-app-router package by running the following command in the terminal:

pnpm add --save solid-app-router
Enter fullscreen mode Exit fullscreen mode

Creating the Navbar and adding basic routes

Create a new folder named components inside the src folder, then create a new file named Navbar.tsx inside the src/components folder. Add the following code in the file:

import { Component } from 'solid-js';
import { Link } from 'solid-app-router';

const Navbar: Component = () => {
  return (
    <header class="bg-purple-600 text-white py-2 px-8 h-16 flex items-center justify-between">
      <Link class="hover:opacity-50 hero" href='/'>REST in Peace</Link>
      <div class="flex items-center gap-4">
        <Link class="hover:opacity-50" href='/about'>About</Link>
      </div>
    </header>
  )
}

export default Navbar;
Enter fullscreen mode Exit fullscreen mode

Now add the following Route configuration in the App.tsx file as follows:

import type { Component } from 'solid-js';
import styles from './App.module.css';
import { hashIntegration, Route, Router, Routes } from 'solid-app-router';
import Navbar from './components/Navbar';

const App: Component = () => {
  return (
    <Router source={hashIntegration()}>
      <div class={styles.App}>
        <Navbar />
      </div>
      <Routes>
        <Route path="/" component={() => <div>Home Component</div>}></Route>
        <Route path="/about" component={() => <div>About Component</div>}></Route>
      </Routes>
    </Router>
  );
};

export default App;

Enter fullscreen mode Exit fullscreen mode

If you look at the app now, you should be able to see the Navbar with the logo and about link. You can click on both to navigate to the different routes.

Navbar with links

Notice that we're using the <Router> component from solid-app-router to wrap all the routing configuration. Inside the Router we have both the Navbar component and the <Routes> component having individual <Route> elements. Each <Route> element defines how the route would work. Following are the possible combinations of props you can provide:

export declare type RouteProps = {
    path: string | string[];
    children?: JSX.Element;
    data?: RouteDataFunc;
} & ({
    element?: never;
    component: Component;
} | {
    component?: never;
    element?: JSX.Element;
    preload?: () => void;
});
Enter fullscreen mode Exit fullscreen mode

Notice that path is required for each route. All other properties are optional. But we can't provide both element and component on the same route. Notice there's an OR operator between the last two conditions being in a bracket:

({
    element?: never;
    component: Component;
} | {
    component?: never;
    element?: JSX.Element;
    preload?: () => void;
})
Enter fullscreen mode Exit fullscreen mode

So for now, we're providing hardcoded functions returning JSX as the component prop for both routes. We'll switch to using element later in this tutorial.

Creating Home and About Components:

Let's create both the Home page and the About page properly now. Create a new folder named pages in the src folder.

Home Component:

Create a new file inside the pages folder named Home.tsx and paste the following code in it:

import { Outlet } from 'solid-app-router';
import { Component } from 'solid-js';

const Home: Component = () => {
  return (
    <div class="flex flex-col md:flex-row gap-4 h-full flex-1">
      <div class="w-full md:w-1/4 bg-gray-200 min-h-full border-gray-300 border p-4 rounded-lg">
        <div class="flex justify-between py-4">
          <h1 class="text-sm ">Rest Requests</h1>
          <button class="flex hover:bg-opacity-60 justify-center items-center p-4 bg-purple-600 rounded-full text-white w-8 h-8" onClick={() => alert('To be implemented')}>
            <div>+</div>
          </button>
        </div>
      </div>
      <div class="flex-1 min-h-full">
        <Outlet />
      </div>
    </div>
  )
}

export default Home;
Enter fullscreen mode Exit fullscreen mode

About Component:

Create a new file named About.tsx inside the pages folder. Then paste the following code in it:

import { Component } from 'solid-js';

const About: Component = () => {
  return (
    <div>
      <h2>This is About</h2>
    </div>
  )
}

export default About;
Enter fullscreen mode Exit fullscreen mode

Now that we have created both the files, let's update the App.tsx file's <Router> element to use the routes as follows:

import type { Component } from 'solid-js';
import { hashIntegration, Route, Router, Routes } from 'solid-app-router';
import Navbar from './components/Navbar';
import About from './pages/About'; // <!-- new import
import Home from './pages/Home'; // <!-- new import

const App: Component = () => {
  return (
    <Router source={hashIntegration()}>
      <div class="flex flex-col h-full min-h-screen">
        <Navbar></Navbar>
        <main class="px-8 py-4 flex-1 flex flex-col h-full">
          <Routes>
            <Route path="/about" element={<About />} />
            <Route path="/" element={<Home />}>
              {/* <Route path="/" element={<RestClientIndex />} />
              <Route
                path="/:id"
                element={<RestClient />}
                data={fetchSelectedRequest}
              /> */}
            </Route>
          </Routes>
        </main>
      </div>
    </Router>
  );
};

export default App;

Enter fullscreen mode Exit fullscreen mode

Now if you go to the Home page, you'll see the following:

Real Components

Creating the Interfaces

We're using TypeScript... and it it super cool. And we're going to work with some Rest API Requests in this app. So we'll create the interfaces required to work with them.
Create a new folder named interfaces inside the src folder. And then create a file inside named rest.interfaces.ts.
Finally, add the following code inside the file:

interface IRequest {
  headers?: {
    [key: string]: string;
  }[];
  method: string;
  url: string;
  body?: any;
}
export interface IRestRequest {
  id: string;
  name: string;
  description: string;
  request: IRequest;
}

export interface IRestResponse {
  data: any;
  status: number;
  headers: any;
}
Enter fullscreen mode Exit fullscreen mode

Notice that we're only exporting two interfaces from this file. The IRestRequest and the IRestResponse interfaces. They represent a Request and Response in the context of our app. Notice that IRestRequest internally uses the IRequest interface for the request property. This request property is the actual request we'll be passing to axios in a later stage in this tutorial.

Creating Requests Data

Let's start creating some data. We'll create some dummy requests to work with. Update the Home.tsx to add the following requests array:

import { Outlet } from 'solid-app-router';
import { Component } from 'solid-js';
import { IRestRequest } from '../interfaces/rest.interfaces';

const Home: Component = () => {
  const requests: IRestRequest[] = [
    {
      id: "1",
      name: "Get Scores",
      description: "Getting scores from server",
      request: {
        method: "GET",
        url: "https://scorer-pro3.p.rapidapi.com/score/game123",
        headers: [
          {
            key: "X-RapidAPI-Host",
            value: "API_HOST_FROM_RAPID_API",
          },
          {
            key: "X-RapidAPI-Key",
            value: "API_KEY_FROM_RAPID_API",
          },
        ],
      },
    },
    {
      id: "2",
      name: "Add Score",
      description: "Adding scores to server",
      request: {
        method: "POST",
        url: "https://scorer-pro3.p.rapidapi.com/score",
        headers: [
          {
            key: "X-RapidAPI-Host",
            value: "API_HOST_FROM_RAPID_API",
          },
          {
            key: "X-RapidAPI-Key",
            value: "API_KEY_FROM_RAPID_API",
          },
        ],
        body: JSON.stringify({
          score: 100,
          gameId: "123",
          userId: "test123",
        }),
      },
    },
  ];

  return (
    <div class="flex flex-col md:flex-row gap-4 h-full flex-1">
      <!-- further code here -->
    </div>
  )
}

export default Home;
Enter fullscreen mode Exit fullscreen mode

Note that we have the data according to our interfaces provided. Also note that each request has this headers property. We're not going to use this in this tutorial. This is just to give you an idea on how you can extend this app to work with Request Headers as well on your own. Or wait for another tutorial by me 🙂!

Creating Request elements

Now that we have the data, let's use that within the same Home.tsx file by using the For element from solid-js to loop over the requests array and to render some requests.
Update the template in the Home.tsx file as follows:

import { Link, Outlet } from "solid-app-router";
import { Component, For } from "solid-js";
import { IRestRequest } from "../interfaces/rest.interfaces";

const Home: Component = () => {
  {/*  ... */}
  return (
    <div class="flex flex-col md:flex-row gap-4 h-full flex-1">
      <div class="w-full md:w-1/4 bg-gray-200 min-h-full border-gray-300 border p-4 rounded-lg">
        <div class="flex justify-between py-4">
          <h1 class="text-sm ">Rest Requests</h1>
          <button
            class="flex hover:bg-opacity-60 justify-center items-center p-4 bg-purple-600 rounded-full text-white w-8 h-8"
            onClick={() => alert("To be implemented")}
          >
            <div>+</div>
          </button>
        </div>
        {/*  👇 We've added this 👇 */}
        <div class="list">
          <For each={requests} fallback={<div>Loading...</div>}>
            {(item) => (
              <Link href={`/${item.id}`} class="relative list__item">
                <div
                  class="p-2 hover:bg-gray-300 cursor-pointer pr-12 rounded-lg mb-2"
                  classList={{
                    "list__item--active": Boolean(
                      location.pathname === `/${item.id}`
                    ),
                  }}
                >
                  <div>{item.name}</div>
                  <div class="text-xs break-all">
                    {item.request.method} {item.request.url}
                  </div>
                </div>
              </Link>
            )}
          </For>
        </div>
      </div>
      <div class="flex-1 min-h-full">
        <Outlet />
      </div>
    </div>
  );
};

export default Home;

Enter fullscreen mode Exit fullscreen mode

Rendering requests list

As you can see, each item being rendered is a <Link> element from solid-app-router. Clicking a request should take you to the details page of the request. And we've not implemented it yet. Each item shows the name, the method and the URL of the request on the view.

Creating a Reactive-Persistent Store using Signals

Since we want our store to be persistent, we'll use the @solid-primitives/storage package to save all the requests in the browser. We'll initiate the store with the hard-coded/dummy data if it is empty. Let's install the package first as follows:

pnpm add --save @solid-primitives/storage
Enter fullscreen mode Exit fullscreen mode

Now create a new file named store.ts inside the src folder and paste the following code inside:

import { IRestRequest } from "./interfaces/rest.interfaces";
import { createStorageSignal } from "@solid-primitives/storage";

export const [restRequests, setRestRequests] = createStorageSignal<
  IRestRequest[]
>(
  "requests",
  [
    {
      id: "1",
      name: "Get Scores",
      description: "Getting scores from server",
      request: {
        method: "GET",
        url: "https://scorer-pro3.p.rapidapi.com/score/game123",
        headers: [
          {
            key: "X-RapidAPI-Host",
            value: "API_HOST_FROM_RAPID_API",
          },
          {
            key: "X-RapidAPI-Key",
            value: "API_KEY_FROM_RAPID_API",
          },
        ],
      },
    },
    {
      id: "2",
      name: "Add Score",
      description: "Adding scores to server",
      request: {
        method: "POST",
        url: "https://scorer-pro3.p.rapidapi.com/score",
        headers: [
          {
            key: "X-RapidAPI-Host",
            value: "API_HOST_FROM_RAPID_API",
          },
          {
            key: "X-RapidAPI-Key",
            value: "API_KEY_FROM_RAPID_API",
          },
        ],
        body: JSON.stringify({
          score: 100,
          gameId: "123",
          userId: "test123",
        }),
      },
    },
  ],
  {
    deserializer: (val): IRestRequest[] => {
      if (val === null) {
        return [];
      }
      return JSON.parse(val);
    },
    serializer: (val) => {
      return JSON.stringify(val);
    },
  }
);

Enter fullscreen mode Exit fullscreen mode

Note that we're using the createStorageSignal method with the type IRestRequest[] to create a storage signal to store an array of our requests. Notice that the first argument of this method has the value "requests" which sets the name of this store both for the app's session as well as the key for the browser's storage (localStorage). The second parameter is the initial value of the store and we're providing our hard-coded/dummy data here.
The third parameter is the storage options. We're passing the methods deserializer and serializer which are responsible for transforming data when reading from the storage and when writing to the storage respectively.
Finally, we're exporting both the restRequests which is a Solid JS accessor to access value from a signal, and the setRestRequests method to update the value of the signal.

Now that we have the storage created, let's use it in the Home.tsx as follows:

import { Link, Outlet } from "solid-app-router";
import { Component, For } from "solid-js";
import { restRequests } from "../store"; // <-- importing accessor

const Home: Component = () => {
  return (
    <div class="flex flex-col md:flex-row gap-4 h-full flex-1">
      <div class="w-full md:w-1/4 bg-gray-200 min-h-full border-gray-300 border p-4 rounded-lg">
        <div class="flex justify-between py-4">
          <h1 class="text-sm ">Rest Requests</h1>
          <button
            class="flex hover:bg-opacity-60 justify-center items-center p-4 bg-purple-600 rounded-full text-white w-8 h-8"
            onClick={() => alert("To be implemented")}
          >
            <div>+</div>
          </button>
        </div>
        <div class="list">
          {/*  Using the accessor here */}
          <For each={restRequests()} fallback={<div>Loading...</div>}>
            {(item) => (
              <Link href={`/${item.id}`} class="relative list__item">
                <div
                  class="p-2 hover:bg-gray-300 cursor-pointer pr-12 rounded-lg mb-2"
                  classList={{
                    "list__item--active": Boolean(
                      location.pathname === `/${item.id}`
                    ),
                  }}
                >
                  <div>{item.name}</div>
                  <div class="text-xs break-all">
                    {item.request.method} {item.request.url}
                  </div>
                </div>
              </Link>
            )}
          </For>
        </div>
      </div>
      <div class="flex-1 min-h-full">
        <Outlet />
      </div>
    </div>
  );
};

export default Home;

Enter fullscreen mode Exit fullscreen mode

Note that we've removed the hardcoded requests from the Home.tsx from the file as we've moved that to the store.ts file. The Home component looks so clean now đŸ«§ You can see the persistent requests in the localStorage as follows in the Chrome Debugger:

Requests Persistent Storage

Creating the Add Request Modal

Since we have the storage implemented now, let's create a way to add/create more requests. This is where the fun begins because we're now starting to add things dynamically to the app. We'll create a modal to add a new request. To do that, create a new file named RequestModal.tsx inside the src/components folder. Add the following code to create a basic modal:

import { Component, ComponentProps, Show } from "solid-js";
import { IRestRequest } from "../interfaces/rest.interfaces";

interface RequestModalProps extends ComponentProps<any> {
  show: boolean;
  onModalHide: (id: string | null) => void;
  request?: IRestRequest;
}

const RequestModal: Component<RequestModalProps> = (
  props: RequestModalProps
) => {
  return (
    <Show when={props.show}>
      <div class="fixed z-50 top-0 left-0 right-0 bottom-0 bg-[rgba(0,0,0,0.75)]">
        <div
          class="relative max-h-[85%] overflow-y-auto top-20 bg-gray-200 max-w-md m-auto h- block p-8 pb-8 border-t-4 border-purple-600 rounded-sm shadow-xl"
        >
          <h5 class="text-4xl font-bold mb-4">
            {(props.request ? "Edit" : "Create") + " Request"}
          </h5>
          <span class="absolute bottom-9 right-8">
            <svg
              xmlns="http://www.w3.org/2000/svg"
              class="w-10 h-10 text-purple-600"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width="2"
                d="M13 10V3L4 14h7v7l9-11h-7z"
              />
            </svg>
          </span>
        </div>
      </div>
    </Show>
  );
};

export default RequestModal;
Enter fullscreen mode Exit fullscreen mode

Note that the RequestModal uses the <Show> component from Solid JS that shows the template/components within it based on the condition supplied to the when attribute. We're relying on the show prop of the RequestModal to tell the <Show> component when to display stuff. This is so that we can control the RequestModal from outside the component to make it visible or hidden.
Now that we have this component, let's add it in the Home.tsx as follows:

import { Link, Outlet, useLocation, useNavigate } from "solid-app-router";
import { Component, createSignal, For } from "solid-js";
import RequestModal from "../components/RequestModal";
import { restRequests } from "../store";

const Home: Component = () => {
  const [showModal, setShowModal] = createSignal(false);
  const navigate = useNavigate();
  const location = useLocation();
  return (
    <div class="flex flex-col md:flex-row gap-4 h-full flex-1">
      <div>
        <button onClick={() => setShowModal(!showModal())}>Click Me</button>
        <RequestModal
          show={showModal()}
          onModalHide={(id: string | null) => {
            setShowModal(!showModal());
          }}
        />
      </div>
      <div class="w-full md:w-1/4 bg-gray-200 min-h-full border-gray-300 border p-4 rounded-lg">
        <div class="flex justify-between py-4">
          <h1 class="text-sm ">Rest Requests</h1>
          <button
            class="flex hover:bg-opacity-60 justify-center items-center p-4 bg-purple-600 rounded-full text-white w-8 h-8"
            onClick={() => alert("To be implemented")}
          >
            <div>+</div>
          </button>
        </div>

        <div class="list">
          <For each={restRequests()} fallback={<div>Loading...</div>}>
            {(item) => (
              <Link href={`/${item.id}`} class="relative list__item">
                <div
                  class="p-2 hover:bg-gray-300 cursor-pointer pr-12 rounded-lg mb-2"
                  classList={{
                    "list__item--active": Boolean(
                      location.pathname === `/${item.id}`
                    ),
                  }}
                >
                  <div>{item.name}</div>
                  <div class="text-xs break-all">
                    {item.request.method} {item.request.url}
                  </div>
                </div>
              </Link>
            )}
          </For>
        </div>
      </div>
      <div class="flex-1 min-h-full">
        <Outlet />
      </div>
    </div>
  );
};

export default Home;

Enter fullscreen mode Exit fullscreen mode

Let's look at what we've done in a few steps apart from the changes in the imports at the top.

const Home: Component = () => {
  const [showModal, setShowModal] = createSignal(false);
  const navigate = useNavigate();
  const location = useLocation();
  // more code
};

export default Home;
Enter fullscreen mode Exit fullscreen mode

We've created a signal showModal that we'll use to show or hide the modal. I.e. we'll pass this as the show prop of the RequestModal later. We also create two constants. Once for navigation and one for accessing location.

<div>
  <button onClick={() => setShowModal(!showModal())}>Click Me</button>
  <RequestModal
    show={showModal()}
    onModalHide={(id: string | null) => {
      setShowModal(!showModal());
    }}
  />
</div>
Enter fullscreen mode Exit fullscreen mode

In the above code, we have a button that toggles the value of showModal signal. And we have the RequestModal being used which has the value of showModal assigned to the show prop. You can see we also provide a function for the onModalHide prop. This will be called when then RequestModal is hidden. We haven't written the logic of it yet.

With all this, you should be able to see this in the app when you click the Click Me text on the left.

Create Request Simple Modal

Notice that we're not able to close the modal yet. We'll work on it in a bit.

Creating the IconButton component

We are going to use some icon buttons throughout the application. So it is a good idea to create a reusable IconButton component. Create a new file in the src/components folder named IconButton.tsx. Then add the following code to it:

import { Component, ComponentProps } from "solid-js";

interface IconButtonProps extends ComponentProps<any> {
  onClick: (event: MouseEvent) => void;
  label: string;
  icon: string;
  type?: "reset" | "submit" | "button";
}

const IconButton: Component<IconButtonProps> = ({
  onClick,
  label,
  icon,
  type,
}) => {
  return (
    <button
      onclick={onClick}
      role="button"
      type={type || "button"}
      title={label}
      class="w-6 h-6 flex transition-all ease-in-out duration-100 hover:scale-125 items-center justify-center text-white bg-purple-600 border border-purple-600 rounded-full hover:bg-purple-700 active:text-white focus:outline-none focus:ring"
    >
      <span class="sr-only">{label}</span>
      <ion-icon name={icon}></ion-icon>
    </button>
  );
};

export default IconButton;
Enter fullscreen mode Exit fullscreen mode

This component is quite simple. We're styling a button in here and using an <ion-icon> element to show the target icon. Look at the IconButtonProps interface to see what possible props we can pass to this component.

Let's replace the "Add Request" button in the Home.tsx file with this new IconButton component. We'll also remove the "Click Me" button from the template as well:

import { Link, Outlet, useLocation, useNavigate } from "solid-app-router";
import { Component, createSignal, For } from "solid-js";
import IconButton from "../components/IconButton";
import RequestModal from "../components/RequestModal";
import { restRequests } from "../store";

const Home: Component = () => {
  const [showModal, setShowModal] = createSignal(false);
  const navigate = useNavigate();
  const location = useLocation();
  return (
    <div class="flex flex-col md:flex-row gap-4 h-full flex-1">
      <div>
        <RequestModal
          show={showModal()}
          onModalHide={(id: string | null) => {
            setShowModal(!showModal());
          }}
        />
      </div>
      <div class="w-full md:w-1/4 bg-gray-200 min-h-full border-gray-300 border p-4 rounded-lg">
        <div class="flex justify-between py-4">
          <h1 class="text-sm ">Rest Requests</h1>
          {/* Replaced the Add Request Button with IconButton */}
          <IconButton
            onClick={() => setShowModal(true)}
            icon="add"
            label="Add Request"
          />
        </div>

        <div class="list">
          <For each={restRequests()} fallback={<div>Loading...</div>}>
            {(item) => (
              <Link href={`/${item.id}`} class="relative list__item">
                <div
                  class="p-2 hover:bg-gray-300 cursor-pointer pr-12 rounded-lg mb-2"
                  classList={{
                    "list__item--active": Boolean(
                      location.pathname === `/${item.id}`
                    ),
                  }}
                >
                  <div>{item.name}</div>
                  <div class="text-xs break-all">
                    {item.request.method} {item.request.url}
                  </div>
                </div>
              </Link>
            )}
          </For>
        </div>
      </div>
      <div class="flex-1 min-h-full">
        <Outlet />
      </div>
    </div>
  );
};

export default Home;

Enter fullscreen mode Exit fullscreen mode

Great! Now let's move towards creating the Add Request form.

Using Solid JS Forms to create requests

We're going to use the solid-forms package to work with forms in this tutorial. I've seen multiple forms libraries but found this one pretty nice. Install the package as follows:

pnpm add --save solid-forms
Enter fullscreen mode Exit fullscreen mode

Implementing the TextField component

Now create a new file under src/components folder and name it TextField.tsx. This component is going to be used whenever we want to show an Input or a TextArea component. Add the following code into the created file:

import { IFormControl } from "solid-forms";
import { Component } from "solid-js";

export const TextField: Component<{
  control: IFormControl<string>;
  label: string;
  placeholder?: string;
  type?: string;
  rows?: number;
  id: string;
  class?: string;
  valueUpdated?: (val: any) => void;
}> = (props) => {
  const type = props.type || "text";
  const onInput = (e: { currentTarget: { value: string } }) => {
    props.control.markDirty(true);
    props.control.setValue(e.currentTarget.value);
  };

  const onBlur = () => {
    props.control.markTouched(true);
    if (props.valueUpdated) {
      props.valueUpdated(props.control.value);
    }
  };
  return (
    <>
      <label class="sr-only" for={props.id}>
        {props.label}
      </label>
      {type === "textarea" ? (
        <textarea
          value={props.control.value}
          rows={props.rows || 3}
          oninput={onInput}
          onblur={onBlur}
          placeholder={props.placeholder}
          required={props.control.isRequired}
          id={props.id}
          class={`w-full p-3 text-sm border-gray-200 rounded-lg ${props.class}`}
        />
      ) : (
        <input
          type="text"
          value={props.control.value}
          oninput={onInput}
          onblur={onBlur}
          placeholder={props.placeholder}
          required={props.control.isRequired}
          id={props.id}
          class={`w-full p-3 text-sm border-gray-200 rounded-lg ${props.class}`}
        />
      )}
    </>
  );
};

Enter fullscreen mode Exit fullscreen mode

Apart from some obvious props, the important implementation here to consider is the onblur and oninput methods used on both the <input> and <textarea> elements in the template. The TextField accepts a control prop which is supposed to be a Form Control from Solid Forms. Whenever there is an input change in the <input> or <textarea> element, the onInput method is triggered and the value of the control is set via the props.control.setValue statement.
Similarly, we let the parent component know that the value has been changed using the valueUpdated prop. Whenever we blur from either the Input or the TextArea, we call the props.valueUpdated method if it is provided (since it is an optional prop).

Creating the RestClientForm

This form is going to be used for both creating and editing a request. And it is going to be heavily based on multiple TextField component. Create a new file named RestClientForm.tsx in the src/components folder. Then add the following code to it:

import { Component } from "solid-js";
import { IRestRequest } from "../interfaces/rest.interfaces";

export const RestClientForm: Component<{
  request?: Partial<IRestRequest>;
  formSubmit: Function;
  formUpdate?: Function;
  actionBtnText: string;
}> = (props) => {
  return (
    <form
      action=""
      class="space-y-4"
      classList={{}}
      onSubmit={(e) => {
        e.preventDefault();
      }}
    >
      <div class="grid grid-cols-1 gap-4">
        <div>
          <label for="name" class="mb-4 block">
            Name
          </label>
          <input placeholder="name" />
        </div>
        <div>
          <label for="url" class="mb-4 block">
            URL
          </label>
          <input placeholder="url" />
        </div>

        <div>
          <label class="my-4 block">Method</label>
          <input placeholder="method" />
        </div>
      </div>
      <div>
        <label class="my-4 block">Body</label>
        <input placeholder="body" />
      </div>

      <div class="mt-4">
        <button
          disabled={false}
          type="submit"
          class="inline-flex items-center disabled:bg-gray-500 justify-center w-full px-5 py-3 text-white bg-purple-600 hover:bg-purple-700 rounded-lg sm:w-auto"
        >
          <span class="font-medium"> {props.actionBtnText} </span>
          <svg
            xmlns="http://www.w3.org/2000/svg"
            class="w-5 h-5 ml-3"
            fill="none"
            viewBox="0 0 24 24"
            stroke="currentColor"
          >
            <path
              stroke-linecap="round"
              stroke-linejoin="round"
              stroke-width="2"
              d="M14 5l7 7m0 0l-7 7m7-7H3"
            />
          </svg>
        </button>
      </div>
    </form>
  );
};

Enter fullscreen mode Exit fullscreen mode

This components accepts a function against the formSubmit prop which is going to be called upon...well... on the form submit 😄! Another mandatory prop is the actionBtnText so we can show the desired label on the submit button.
If you have a look at the above code closely, you're going to notice that we're not using any TextField components so far đŸ€·â€â™‚ïž. Well, hold your horses. For now, let's use this RestClientForm component in the RequestModal component and see how everything looks. Add the RestClientForm in the RequestModal.tsx file as follows:

import { Component, ComponentProps, Show } from "solid-js";
import { IRestRequest } from "../interfaces/rest.interfaces";
import { setRestRequests, restRequests } from "../store";
import { RestClientForm } from "./RestClientForm";

interface RequestModalProps extends ComponentProps<any> {
  show: boolean;
  onModalHide: (id: string | null) => void;
  request?: IRestRequest;
}

const RequestModal: Component<RequestModalProps> = (
  props: RequestModalProps
) => {
  return (
    <Show when={props.show}>
      <div class="fixed z-50 top-0 left-0 right-0 bottom-0 bg-[rgba(0,0,0,0.75)]">
        <div class="relative max-h-[85%] overflow-y-auto top-20 bg-gray-200 max-w-md m-auto h- block p-8 pb-8 border-t-4 border-purple-600 rounded-sm shadow-xl">
          <h5 class="text-4xl font-bold mb-4">
            {(props.request ? "Edit" : "Create") + " Request"}
          </h5>
          <RestClientForm
            formSubmit={(request: IRestRequest) => {
              const id = self.crypto?.randomUUID() || Date.now().toString();
              setRestRequests([
                ...(restRequests() || []),
                {
                  ...request,
                  id,
                },
              ]);
              props.onModalHide(id);
            }}
            actionBtnText={"Save"}
          />
          <span class="absolute bottom-9 right-8">
            <svg
              xmlns="http://www.w3.org/2000/svg"
              class="w-10 h-10 text-purple-600"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width="2"
                d="M13 10V3L4 14h7v7l9-11h-7z"
              />
            </svg>
          </span>
        </div>
      </div>
    </Show>
  );
};

export default RequestModal;

Enter fullscreen mode Exit fullscreen mode

RestClientForm with simple html elements

Notice that we're passing a function against the formSubmit prop here. If a request is passed to this function in response to submitting the form, then we create a unique ID for that request using self.crypto?.randomUUID() (pretty neat trick TBH) and add the request to the requests list using the setRestRequests update function. Remember that this is part of a Signal, and not just a normal Signal but a Signal from @solid-primitives/storage package. So this will be updated both in the App's current state as well as in the local storage. Finally, you can see that we're calling the props.onModalHide(id) statement to close the modal after creating the new request and saving it.

But wait! If you try it out now, it won't work. As we're not using any controls, nor the TextField components yet. So let's create the controls first.

Creating Solid Form Controls

We're now going to make use of the Control Factory from solid-forms. This allows us to create a Form Group that we can easily manage to handle form inputs and react to it.
To do so, update the RestClientForm.tsx file as follows:

import { Component } from "solid-js";
import { IRestRequest } from "../interfaces/rest.interfaces";
import { createFormGroup, createFormControl } from 'solid-forms';

const controlFactory = () => {
  return createFormGroup({
    name: createFormControl<string>("New Request", {
      required: true,
      validators: (val: string) => {
        return !val.length ? {isMissing: true} : null;
      }
    }),
    request: createFormGroup({
      method: createFormControl<string>("GET"),
      body: createFormControl<string>(""),
      url: createFormControl<string>(""),
    }),
  });
};

// rest of the code

Enter fullscreen mode Exit fullscreen mode

The controlFactory method returns a Form Group of type IFormGroup from Solid Forms. This means that we have a Form Group which can now have multiple Form Controls and Form Groups within it. In our case, we have the name as a Form Control as you can see it uses the createFormControl method. Notice that the first argument of the method is the initial value, and the second argument is the configuration which contains required: true and the validators method for custom validation.
The request property inside our Form Group is yet another Form Group which contains three Form Controls, method, body, and url.

Now we're going to wrap our RestClientForm component with solid forms using the withControl method.
Create a wrapper component just below the controlFactory code as follows:

import { Component } from "solid-js";
import { IRestRequest } from "../interfaces/rest.interfaces";
import { createFormGroup, createFormControl, withControl } from "solid-forms";
import { TextField } from "./TextField";

const controlFactory = () => {
  return createFormGroup({
    name: createFormControl<string>("New Request", {
      required: true,
      validators: (val: string) => {
        return !val.length ? { isMissing: true } : null;
      },
    }),
    request: createFormGroup({
      method: createFormControl<string>("GET"),
      body: createFormControl<string>(""),
      url: createFormControl<string>(""),
    }),
  });
};

export const RestClientForm = withControl<
  {
    request?: Partial<IRestRequest>;
    formSubmit: Function;
    formUpdate?: Function;
    actionBtnText: string;
  },
  typeof controlFactory
>({
  controlFactory,
  component: (props) => {
    const controlGroup = () => props.control.controls;
    const requestControlGroup = () => controlGroup().request.controls;
    const request = () => props.request;

    return (
      <form
        action=""
        class="space-y-4"
        classList={{
          "is-valid": props.control.isValid,
          "is-invalid": !props.control.isValid,
          "is-touched": props.control.isTouched,
          "is-untouched": !props.control.isTouched,
          "is-dirty": props.control.isDirty,
          "is-clean": !props.control.isDirty,
        }}
        onSubmit={(e) => {
          e.preventDefault();
          const params = {
            ...props.control.value,
            request: {
              ...props.control.value.request,
            },
          };
          props.formSubmit(params);
        }}
      >
        <div class="grid grid-cols-1 gap-4">
          <div>
            <label for="name" class="mb-4 block">
              Name
            </label>
            <TextField
              placeholder="name"
              id="name"
              label="Name"
              control={controlGroup().name}
            />
          </div>
          <div>
            <label for="url" class="mb-4 block">
              URL
            </label>
            <TextField
              placeholder="url"
              id="url"
              label="Url"
              control={requestControlGroup().url}
            />
          </div>

          <div>
            <label class="my-4 block">Method</label>
            <TextField
              id="method"
              label="Method"
              placeholder="method"
              control={requestControlGroup().method}
            />
          </div>
        </div>
        <div>
          <label class="my-4 block">Body</label>
          <TextField
            id="body"
            type="textarea"
            label="Body"
            placeholder="body"
            control={requestControlGroup().body}
          />
        </div>

        <div class="mt-4">
          <button
            disabled={!props.control.isValid}
            type="submit"
            class="inline-flex items-center disabled:bg-gray-500 justify-center w-full px-5 py-3 text-white bg-purple-600 hover:bg-purple-700 rounded-lg sm:w-auto"
          >
            <span class="font-medium"> {props.actionBtnText} </span>
            <svg
              xmlns="http://www.w3.org/2000/svg"
              class="w-5 h-5 ml-3"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path
                stroke-linecap="round"
                stroke-linejoin="round"
                stroke-width="2"
                d="M14 5l7 7m0 0l-7 7m7-7H3"
              />
            </svg>
          </button>
        </div>
      </form>
    );
  },
});

Enter fullscreen mode Exit fullscreen mode

In the above code, you'll notice that our RestClientForm component is now using the withControl method and uses the controlFactory we created before. As a result, we get the Solid Form (the Form Group) we create using the factory within the component function. The form group we're receiving is as props.control property. This is a Solid JS Signal. YES! Which means this is reactive.
We distribute the controls into two groups.

  • The main group using the controlGroup accessor
  • The request control group using requestControlGroup accessor

You'll also notice that we get a bunch of properties from Solid Forms that we can use to apply different classes to our forms:

<form
  action=""
  class="space-y-4"
  classList={{
    "is-valid": props.control.isValid,
    "is-invalid": !props.control.isValid,
    "is-touched": props.control.isTouched,
    "is-untouched": !props.control.isTouched,
    "is-dirty": props.control.isDirty,
    "is-clean": !props.control.isDirty,
  }}
</form>
Enter fullscreen mode Exit fullscreen mode

And finally, we're passing each control to the respective TextField component via the control prop. Notice how we access the control by calling the accessor and then accessing the property. For example, requestControlGroup().url instead of requestControlGroup.url.

Note: You'll notice that the element I've used for method is a TextField instead of a <select> component. This is intentional to not make the tutorial more complicated than it has to be. Feel free to adjust the code on your own 🙂

If you look at the app now, it should look as follows:

RestClientForm with TextField components

Looks cool 😎, right? Try creating a request now. You should be able to see it added to the UI as well as in the localStorage. This is because when we submit the form, we call the props.formSubmit method from the RestClientForm. This in turn calls the provided method in RestclientForm and that adds the new request.

Solid JS Directives (your first one)

We're going to implement a Directive now. A directive is an implementation like a component, but it usually doesn't have a template and works works purely with the DOM most of the times. For our case, we're going to implement a directive that closes the RequestModal when we click outside of it.
Create a new folder inside the src folder named directives and create a file inside it named click-outside.directive.ts. Then add the following code to it:

import { Accessor, onCleanup } from "solid-js";

export default function clickOutside(el: Element, accessor: Accessor<any>) {
  const onClick = (e: Event) =>
    !el.contains(e.target as Node) && accessor()?.();
  document.body.addEventListener("click", onClick);

  onCleanup(() => document.body.removeEventListener("click", onClick));
}

Enter fullscreen mode Exit fullscreen mode

You can see the code is pretty minimal. But what does it do? đŸ€”
The directive itself is a function that has two arguments. SolidJS provides the first argument as the element the directive is applied to. And the second argument is the value provided to the directive by us. In this case, it will be a function that closes the modal.
Notice that we register a click event on document.body element which should check if we're clicking inside or outside the directive. And if we click outside, it calls the accessor() to get the function to be called, and then calls it via the accessor()?.(); statement. Seems a bit confusing. Just get over it 😄

We also use the onCleanup hook from Solid JS to remove the event listener when the element to which the directive is applied to is no longer on the DOM. In our case, when we open the RequestModal, the directive would come to life and the event listener will be registered. And when the modal is closed, the directive will be destroyed and the event listener will be removed.

Let's use the directive now in the RequestModal.tsx as follows:

import { Component, ComponentProps, Show } from "solid-js";
import { IRestRequest } from "../interfaces/rest.interfaces";
import { setRestRequests, restRequests } from "../store";
import { RestClientForm } from "./RestClientForm";
import outsideDirective from "../directives/click-outside.directive"; // <-- new import

// https://github.com/solidjs/solid/discussions/845
const clickOutside = outsideDirective; // <-- remapping to `clickOutside` variable

interface RequestModalProps extends ComponentProps<any> {
  show: boolean;
  onModalHide: (id: string | null) => void;
  request?: IRestRequest;
}

const RequestModal: Component<RequestModalProps> = (
  props: RequestModalProps
) => {
  return (
    <Show when={props.show}>
      <div class="fixed z-50 top-0 left-0 right-0 bottom-0 bg-[rgba(0,0,0,0.75)]">
        <div
          class="relative max-h-[85%] overflow-y-auto top-20 bg-gray-200 max-w-md m-auto h- block p-8 pb-8 border-t-4 border-purple-600 rounded-sm shadow-xl"
          use:clickOutside={() => {  {/** Using the directive here */}
            props.onModalHide(null);
          }}
        >
          <h5 class="text-4xl font-bold mb-4">
            {(props.request ? "Edit" : "Create") + " Request"}
          </h5>
          <RestClientForm
            ...
          />
          <span class="absolute bottom-9 right-8">
            ...
          </span>
        </div>
      </div>
    </Show>
  );
};

export default RequestModal;

Enter fullscreen mode Exit fullscreen mode

You'll notice that TypeScript isn't happy again as it doesn't understand what clickOutside is as a directive.

directive making TS sad

Let's make TypeScript happy. Update the solid-js.d.ts file inside the types folder as follows:

import "solid-js";

declare module "solid-js" {
  namespace JSX {
    interface IntrinsicElements {
      "ion-icon": any;
    }
    interface Directives {
      clickOutside?: () => void;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

TypeScript should be happy now! You may have also noticed that we're doing a remapping of variables there from outsideDirective to clickOutside. That's because of a TypeScript + Solid JS issue that I've linked in the code.
If you open the Request Modal now and click outside of it, you'll see the modal closing automatically. Yayy! 🙌

Deleting Requests

Now that we can add requests, close the modal automatically on clicking outside and save the requests persistently, let's work on deleting requests.
To delete a request we'll add a delete button on each list item in the left sidebar. To do that, update the Home.tsx as follows:

// existing imports
import { restRequests, setRestRequests } from "../store";

const Home: Component = () => {
  const [showModal, setShowModal] = createSignal(false);
  const navigate = useNavigate();
  const location = useLocation();
  return (
    <div class="flex flex-col md:flex-row gap-4 h-full flex-1">
      <div>
        <RequestModal
          show={showModal()}
          onModalHide={(id: string | null) => {
            setShowModal(!showModal());
          }}
        />
      </div>
      <div class="w-full md:w-1/4 bg-gray-200 min-h-full border-gray-300 border p-4 rounded-lg">
        <div class="flex justify-between py-4">
          <h1 class="text-sm ">Rest Requests</h1>
          {/* Replaced the Add Request Button with IconButton */}
          <IconButton
            onClick={() => setShowModal(true)}
            icon="add"
            label="Add Request"
          />
        </div>

        <div class="list">
          <For each={restRequests()} fallback={<div>Loading...</div>}>
            {(item) => (
              <Link href={`/${item.id}`} class="relative list__item">
                <div
                  class="p-2 hover:bg-gray-300 cursor-pointer pr-12 rounded-lg mb-2"
                  classList={{
                    "list__item--active": Boolean(
                      location.pathname === `/${item.id}`
                    ),
                  }}
                >
                  <div>{item.name}</div>
                  <div class="text-xs break-all">
                    {item.request.method} {item.request.url}
                  </div>
                </div>
                {/* Delete Request Button */}
                <button
                  onclick={(e: MouseEvent) => {
                    e.preventDefault();
                    e.stopImmediatePropagation();
                    if (restRequests()?.length) {
                      const requests = restRequests() || [];
                      setRestRequests(requests.filter((i) => i.id !== item.id));
                      if (location.pathname === `/${item.id}`) {
                        navigate("/");
                      }
                    }
                  }}
                  class="absolute text-xl hover:scale-125 transition-all ease-in-out duration-100 hover:text-red-700 text-red-600 right-2 top-0 bottom-0 m-auto"
                >
                  <ion-icon name="trash"></ion-icon>
                </button>
              </Link>
            )}
          </For>
        </div>
      </div>
      <div class="flex-1 min-h-full">
        <Outlet />
      </div>
    </div>
  );
};

export default Home;

Enter fullscreen mode Exit fullscreen mode

If you look at the app after this change, you're going to see a delete button on every request. We're going to change that to show it only on the item being hovered.
Look at the onclick handler of the delete button, notice that we use the requests.filter method to remove the request from array using the request's id. And we're then using the setRestRequests updater function to persist them. Try the delete button to delete some requests.

Let's add some CSS to make the delete button visible only on hover of the request item. Create a new file named Home.css and add the following code inside it:

.list .list__item ion-icon {
  display: none;
}

.list .list__item:hover ion-icon {
  display: flex;
}

.list .list__item:hover .list__item--active + ion-icon {
  @apply text-white;
}

.list .list__item--active {
  @apply bg-purple-600 text-white;
}

Enter fullscreen mode Exit fullscreen mode

Finally, import the Home.css file inside Home.tsx file as follows:

// existing imports
import { restRequests, setRestRequests } from "../store";
import "./Home.css";

Enter fullscreen mode Exit fullscreen mode

If you look at the app now, we only show the delete button on the hovered request item.

Delete btn only on hover

Awesome, we're now able to create requests, delete requests, save then persistently and have a really good UI/UX for the app. Feel free to modify how it looks and make it your own 🎉

Conclusion

In this tutorial, we've learnt a couple of concepts when it comes to Solid JS. We learnt:

  • Getting started with Solid JS
  • Signals
  • Persisting data into storage
  • Working with Routes using solid-app-router
  • Working with Forms using solid-forms
  • Creating directives

I enjoyed writing this article and I hope you learnt quite a few things from it. If you did, do react 👏 to this post and bookmark it. This tutorial is also available on YouTube so you can check out both Part1 and Part2 there.

If you'd like to connect, here are the links to my socials:

Twitter, YouTube, LinkedIn, GitHub

Top comments (2)

Collapse
 
artydev profile image
artydev

Great work,
Thank you very much

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

Hi. You seem to be pretty good at front-end development. Can I interest you in a non-profit gig? It is in the open source realm.

I created wj-config as a universal configuration package. The secret is 0 dependencies and ES modules. The repository, however, needs examples for technologies I am not familiar with and you seem to know about them a lot more than I do.

Image description