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 styleJavaScript 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") -
JSONfor the type of file to use -
pnpmto 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
controlGroupaccessor - The
requestcontrol group usingrequestControlGroupaccessor
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
methodis aTextFieldinstead 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.