10 min read

Tutorial: Build the Linkedin "Add experience" form

The first step is to setup a boilerplate with Vite, Typescript and Tailwind CSS.

Next, let's go over what we need to build:

  • when first loading the app, we will show a list of all the job experiences the user already has; there will be a button to add a new one
  • we will need a modal to display the form in - we can use the native HTML <dialog> element with some custom styling
  • we will need the form itself, for adding an experience - AddExperienceForm
  • after the user clicks Save, we will append the newly added experience to the initial list

Since the modal and the list of experiences is not the main focus of this challenge, we can just use the code provided in the challenge description:

import { useState } from "react";
import { JobExperience } from "./types";
import AddExperienceForm from "./components/AddExperienceForm";
import { sampleJobExperiences } from "./sample-data";

function App() {
  const [experienceList, setExperienceList] =
    useState<JobExperience[]>(sampleJobExperiences);
  const [showAddExperienceForm, setShowAddExperienceForm] = useState(false);
  return (
    <div className="bg-gray-300 min-h-screen p-4">
      <div className="container mx-auto p-4 max-w-4xl border-1 border-gray-400 rounded-xl bg-white shadow-lg">
        <h1 className="text-3xl font-bold mb-6">My Linkedin Profile</h1>
        <div className="flex items-center justify-between p-4 bg-gray-200">
          <h1 className="text-2xl font-bold">Experience</h1>
          <button
            onClick={() => setShowAddExperienceForm(true)}
            className="cursor-pointer hover:bg-gray-100 p-2 rounded-2xl"
          >
            Add
          </button>
        </div>
        {experienceList.length === 0 && (
          <div className="flex items-center justify-center h-64">
            <p className="text-gray-500">No experience added yet.</p>
          </div>
        )}
        {experienceList.map((jobExperience) => (
          <div className="p-4">
            <h2 className="text-xl font-semibold">{jobExperience.job_title}</h2>
            <p>{jobExperience.company}</p>
            <p className="text-gray-500">
              {jobExperience.start_date.month} {jobExperience.start_date.year}
              {jobExperience.is_current
                ? " - Present"
                : ` - ${jobExperience.end_date?.month} ${jobExperience.end_date?.year}`}
            </p>
          </div>
        ))}
        {showAddExperienceForm && (
          <dialog className="fixed inset-0 bg-black/60 flex items-baseline pt-10 justify-center w-full h-full">
            <div className="bg-white rounded shadow-lg container w-3xl">
              <AddExperienceForm
                onSubmit={(newExperience) => {
                  setExperienceList((prev) => [newExperience, ...prev]);
                  setShowAddExperienceForm(false);
                }}
                onCancel={() => {
                  setShowAddExperienceForm(false);
                }}
              />
            </div>
          </dialog>
        )}
      </div>
    </div>
  );
}

export default App;

Typing the Job experience

Next, let's create the Typescript type for the JobExperience.

// types.ts
export type JobExperience = {
  job_title: string;
  employment_type: string;
  company: string;
  is_current: boolean;
  start_date: { month: string; year: string };
  end_date?: { month: string; year: string };
};

Note we made the end_date optional, as "current" jobs don't have an end date yet.

You'll notice there is a problem with this type though - you could have a job that has is_current: true but end_date defined. So how do we ensure only the right combinations are allowed? You would usually achieve this using a Typescript union:

type Job = {
  job_title: string;
  employment_type: string;
  company: string;
  start_date: { month: string; year: string };
};

type CurrentJob = Job & {
  is_current: true;
};

type PreviousJob = Job & {
  is_current: false;
  end_date: { month: string; year: string };
};

export type JobExperience = CurrentJob | PreviousJob;

This is a bit more verbose, but our types now perfectly reflect the structure of our data.

Creating the form - HTML

Next, let's create the form inside the AddExperienceForm component.
To start, let's add it as simple HTML. This is how it would look without any styling:

import { FormEventHandler } from "react";
import { JobExperience } from "../types";

type Props = {
  onSubmit: (newExperience: JobExperience) => void;
  onCancel: () => void;
};

const MONTH_OPTIONS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
];
const YEAR_OPTIONS = Array.from(
  { length: 50 },
  (_, i) => new Date().getFullYear() - i
).map((year) => year.toString());

const AddExperienceForm = ({ onSubmit }: Props) => {
  const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault();
    const data = new FormData(e.target as HTMLFormElement);
    const newExperience = Object.fromEntries(data);
    console.log(newExperience);
  };
  return (
    <form onSubmit={handleSubmit} className="flex flex-col p-4 gap-3">
      <label>
        <span>Title*</span>
        <input
          type="text"
          name="job_title"
          placeholder="Ex: Retail Sales Manager"
        ></input>
      </label>
      <label>
        <span>Employment type</span>
        <select name="employment_type">
          <option value="">Please select</option>
          <option value="Full-time">Full-time</option>
          <option value="Part-time">Part-time</option>
          <option value="Permanent">Permanent</option>
          <option value="Self-employed">Self-employed</option>
          <option value="Freelance">Freelance</option>
          <option value="Contract">Contract</option>
          <option value="Internship">Internship</option>
          <option value="Apprenticeship">Apprenticeship</option>
        </select>
      </label>
      <label>
        <span>Company or organisation*</span>
        <input type="text" name="company" placeholder="Ex: Microsoft"></input>
      </label>
      <label>
        <input type="checkbox" name="is_current"></input>
        <span>I am currently working in this role</span>
      </label>
      <div>
        <span>Start date*</span>
        <div>
          <select name="start_date.month">
            {MONTH_OPTIONS.map((month) => (
              <option key={month} value={month}>
                {month}
              </option>
            ))}
          </select>
          <select name="start_date.year">
            {YEAR_OPTIONS.map((year) => (
              <option key={year} value={year}>
                {year}
              </option>
            ))}
          </select>
        </div>
      </div>
      <div>
        <span>End date*</span>
        <div>
          <select name="end_date.month">
            {MONTH_OPTIONS.map((month) => (
              <option key={month} value={month}>
                {month}
              </option>
            ))}
          </select>
          <select name="end_date.year">
            {YEAR_OPTIONS.map((year) => (
              <option key={year} value={year}>
                {year}
              </option>
            ))}
          </select>
        </div>
      </div>
      <div>
        <button type="submit">Save</button>
      </div>
    </form>
  );
};

export default AddExperienceForm;

A few things to note:

  • we added the name attribute to each input or select, so that we can read the values using FormData
  • we use the exact placeholders and select options as Linkedin does

Next, let's style the form:

import { FormEventHandler } from "react";
import { JobExperience } from "../types";

type Props = {
  onSubmit: (newExperience: JobExperience) => void;
  onCancel: () => void;
};

const MONTH_OPTIONS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
];
const YEAR_OPTIONS = Array.from(
  { length: 50 },
  (_, i) => new Date().getFullYear() - i
).map((year) => year.toString());

const AddExperienceForm = ({ onSubmit, onCancel }: Props) => {
  const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault();
    const data = new FormData(e.target as HTMLFormElement);
    const newExperience = Object.fromEntries(data);
    console.log(newExperience);
  };

  return (
    <form onSubmit={handleSubmit}>
      <header className="flex justify-between items-center p-4 border-b-1 border-gray-300">
        <h2 className="text-2xl font-bold">Add Experience</h2>
        <button
          type="button"
          onClick={onCancel}
          className="cursor-pointer hover:bg-gray-100 p-2 rounded-2xl"
        >
          Close
        </button>
      </header>
      <div className="overflow-y-auto max-h-[calc(100vh_-_12rem)] flex flex-col gap-4 text-gray-600 text-sm py-3 p-4 ">
        <label className="flex flex-col gap-1">
          <span>Title*</span>
          <input
            type="text"
            name="job_title"
            placeholder="Ex: Retail Sales Manager"
            className="p-2 border-1 border-gray-500 rounded"
          ></input>
        </label>
        <label className="flex flex-col gap-1">
          <span>Employment type</span>
          <select
            name="employment_type"
            className="p-2 border-1 border-gray-500 rounded"
          >
            <option value="">Please select</option>
            <option value="Full-time">Full-time</option>
            <option value="Part-time">Part-time</option>
            <option value="Permanent">Permanent</option>
            <option value="Self-employed">Self-employed</option>
            <option value="Freelance">Freelance</option>
            <option value="Contract">Contract</option>
            <option value="Internship">Internship</option>
            <option value="Apprenticeship">Apprenticeship</option>
          </select>
        </label>
        <label className="flex flex-col gap-1">
          <span>Company or organisation*</span>
          <input
            type="text"
            name="company"
            placeholder="Ex: Microsoft"
            className="p-2 border-1 border-gray-500 rounded"
          ></input>
        </label>
        <label className="flex flex-row gap-1">
          <input type="checkbox" name="is_current"></input>
          <span>I am currently working in this role</span>
        </label>
        <div className="flex flex-col gap-1">
          <span>Start date*</span>
          <div className="flex gap-2">
            <select
              name="start_date.month"
              className="p-2 border-1 border-gray-500 rounded w-1/2"
            >
              {MONTH_OPTIONS.map((month) => (
                <option key={month} value={month}>
                  {month}
                </option>
              ))}
            </select>
            <select
              name="start_date.year"
              className="p-2 border-1 border-gray-500 rounded w-1/2"
            >
              {YEAR_OPTIONS.map((year) => (
                <option key={year} value={year}>
                  {year}
                </option>
              ))}
            </select>
          </div>
        </div>
        <div className="flex flex-col gap-1">
          <span>End date*</span>
          <div className="flex gap-2">
            <select
              name="end_date.month"
              className="p-2 border-1 border-gray-500 rounded w-1/2"
            >
              {MONTH_OPTIONS.map((month) => (
                <option key={month} value={month}>
                  {month}
                </option>
              ))}
            </select>
            <select
              name="end_date.year"
              className="p-2 border-1 border-gray-500 rounded w-1/2"
            >
              {YEAR_OPTIONS.map((year) => (
                <option key={year} value={year}>
                  {year}
                </option>
              ))}
            </select>
          </div>
        </div>
      </div>
      <div className="flex justify-end border-t-1 border-gray-300 p-4">
        <button
          type="submit"
          className="bg-blue-500 text-white px-4 py-1 font-semibold rounded-3xl cursor-pointer hover:bg-blue-600"
        >
          Save
        </button>
      </div>
    </form>
  );
};

export default AddExperienceForm;

Here is how it looks:

Adding React Hook Form

With the HTML & CSS in place, let's hook React Hook Form so we also add data to the homepage main list.

First, we'll install it:

npm install react-hook-form

Next, we'll hook the form to react-hook-form:

And it works!

Adding basic validation

Next, let's add some basic validation.
First, let's install zod:

npm install zod

Next, to be able to use it with React Hook Form, we will also need to install the resolvers package:

npm install @hookform/resolvers

Then, to validate the form, we will need to define a schema. In Zod, the schemas match the Typescript type, so we can follow a similar structure:

const JobSchema = z.object({
  job_title: z.string().min(1, { message: "⛔️ Title is a required field" }),
  employment_type: z.string(),
  company: z.string().min(1, {
    message: "⛔️ Company is a required field",
  }),
  start_date: z
    .object({
      month: z.string(),
      year: z.string(),
    })
    .required()
    .refine((data) => data.month && data.year, {
      message: "⛔️ Start date is a required field",
    }),
});

const CurrentJobSchema = JobSchema.extend({
  is_current: z.literal(true),
});

const PastJobSchema = JobSchema.merge(
  z.object({
    is_current: z.literal(false),
    end_date: z
      .object({
        month: z.string(),
        year: z.string(),
      })
      .required()
      .refine((data) => data.month && data.year, {
        message: "⛔️ Start and end dates are required",
      }),
  })
);

const JobExperienceSchema: ZodType<JobExperience> = z.union([
  CurrentJobSchema,
  PastJobSchema,
]);

Then, we can hook the schema to the form when initialising the useForm hook:

const { register, handleSubmit } = useForm<JobExperience>({
    resolver: zodResolver(JobExperienceSchema),
});

And that's it! We now have validation.
However, we don't show any error messages, so let's add those as well.

Here is an example for the title:

{errors.job_title && (
    <span className="text-red-400">{errors.job_title.message}</span>
)}

For the error, we need to some type casting (not sure how to fix this some other way tbh 😅):

{(errors as FieldErrors<PreviousJob>).end_date && (
	<span className="text-red-400">
	  {(errors as FieldErrors<PreviousJob>).end_date?.message}
	</span>
  )}

And now we can now also see the erorr messages:

Adding dynamic validation and form fields

Last, let's make the form dynamic.
We want to:

  • disable the "End date" fields when the "Is current" checkbox is checked
  • only validate the end date for past jobs

First, to make the fields disabled based on is_current, we can use the watch helper from React Hook Form:

const {
    ...
    watch,
  } = useForm<JobExperience>({
    resolver: zodResolver(JobExperienceSchema),
  });

const isCurrent = watch("is_current");

<div className="flex flex-col gap-1">
	  <span>End date*</span>
	  <div className="flex gap-2">
		<select
		  className="p-2 border-1 border-gray-500 rounded w-1/2 disabled:bg-gray-300"
		  {...register("end_date.month")}
		  disabled={isCurrent}
>
		  ...
		</select>
		<select
		  className="p-2 border-1 border-gray-500 rounded w-1/2 disabled:bg-gray-300"
		  {...register("end_date.year")}
		  disabled={isCurrent}
>
		  ...
		</select>
	  </div>
	  ...
</div>

Then, if the user had already filled in some values in "End date", after they check the "Is checked" checkbox, we want to clear them:

const {
    ...
    setValue,
  } = useForm<JobExperience>({ ...});

useEffect(() => {
    if (isCurrent === true) {
      setValue("end_date.month", "");
      setValue("end_date.year", "");
    }
}, [isCurrent, setValue]);

And that's it!

Refine validation

You probably noticed we don't check that the end date is after the start date. Let's add that as well. We can do it using the refine method from zod:

const PastJobSchema = JobSchema.merge(
  z.object({
    is_current: z.literal(false),
    end_date: z
      .object({
        month: z.string(),
        year: z.string(),
      })
      .required()
      .refine((data) => data.month && data.year, {
        message: "⛔️ Start and end dates are required",
      }),
  })
).refine(
  (data) => {
    const startDate = new Date(
      parseInt(data.start_date.year),
      parseInt(data.start_date.month)
    );
    const endDate = new Date(
      parseInt(data.end_date.year),
      parseInt(data.end_date.month)
    );

    return endDate > startDate;
  },
  {
    message: "⛔️ End date can’t be earlier than start date",
    path: ["end_date"],
  }
);

And it works:


That's it!
You can check out the full working code over at: https://github.com/reactpractice-dev/linkedin-add-experience-form

Here is the completed flow again:

0:00
/0:14

Get the React Practice Calendar!

28 days of focused practice of increasing difficulty, going through everything from Fundamentals, Data fetching, Forms and using Intervals in React.

You will also get notified whenever a new challenge is published.