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
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
Then run the following command to generate the tailwind config
npx tailwindcss init -p
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: [],
}
Now add the following CSS in the src/index.css
file:
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Existing CSS Code */
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">
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;
}
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>
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>
);
};
You'll notice that TypeScript isn't happy at all at this moment and may show something like this:
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;
}
}
}
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
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
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"
]
}
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
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;
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;
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.
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;
});
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;
})
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;
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;
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;
Now if you go to the Home page, you'll see the following:
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;
}
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;
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;
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
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);
},
}
);
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;
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:
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;
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;
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;
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>
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.
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;
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;
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
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}`}
/>
)}
</>
);
};
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>
);
};
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;
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
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>
);
},
});
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 usingrequestControlGroup
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>
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 aTextField
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:
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));
}
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;
You'll notice that TypeScript isn't happy again as it doesn't understand what clickOutside
is as a directive.
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;
}
}
}
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;
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;
}
Finally, import the Home.css
file inside Home.tsx
file as follows:
// existing imports
import { restRequests, setRestRequests } from "../store";
import "./Home.css";
If you look at the app now, we only show the delete button on the hovered request item.
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:
Top comments (2)
Great work,
Thank you very much
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.