6 min read

Tutorial: Create a simple Contact Book app

đź’ˇ
This is a detailed tutorial that solves the Contact book app exercise.
If you haven't already, try to solve it yourself first before reading the solution!

Step 1: Ability to add contacts

Let’s start by allowing users to add new persons to their contact book.

Since the project uses Typescript, it helps to first define the type for a Person:

type Person = {
  name: string;
  city: string;
};

Next, we’ll create a simple form at the top of the page and a basic list view of added persons:

import { useState } from "react";

type Person = {
  name: string;
  city: string;
};

const ContactBook = () => {
  const [newPerson, setNewPerson] = useState<Person>(null);
  const [persons, setPersons] = useState<Person[]>([]);

  const handleAddContact = () => {
    if (!newPerson) {
      return;
    }

    setPersons([...persons, { ...newPerson }]);
    setNewPerson(null);
  };

  return (
    <div>
      <form onSubmit={handleAddContact}>
        <div style={{ border: "1px solid lightgray", padding: "10px" }}>
          <input
            type="text"
            placeholder="Enter the contact's name"
            value={newPerson?.name}
            onChange={(e) =>
              setNewPerson({ ...newPerson, name: e.target.value })
            }
          />
          <input
            type="text"
            placeholder="Enter the contact's city"
            value={newPerson?.city}
            onChange={(e) =>
              setNewPerson({ ...newPerson, city: e.target.value })
            }
          />
          <button type="submit">Add contact</button>
        </div>
      </form>
    </div>
  );
};

export default ContactBook;

The app is very simple at this point, but it allows us to get started.

Screenshot2023-01-12at084223

Step 2: Extract form to its own component

Next, before we create the actual person cards, let’s extract the add contact form in a separate component, as it takes quite a lot of room in the main file.

This how the main file will look like at the end:

import { useState } from "react";
import AddContactForm from "./add-contact-form";
import { Person } from "./Person";

const ContactBook = () => {
  const [persons, setPersons] = useState<Person[]>([]);

  const handleAddContact = (newPerson) => {
    setPersons([...persons, { ...newPerson }]);
  };

  return (
    <div>
      <AddContactForm onAddContact={handleAddContact} />
      <div>
        <ul>
          {persons.map((person) => (
            <li key={person.name}>
              {person.name} ({person.city})
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default ContactBook;

Notice we also had to extract the Person file to its own file, so it can be reused in multiple places:

// Person.ts
export type Person = {
  name: string;
  city: string;
};

The add contact form will then be:

import { useState } from "react";
import { Person } from "./Person";

const AddContactForm = ({ onAddContact }) => {
  const [newPerson, setNewPerson] = useState<Person>(null);

  const handleAddContact = (e) => {
    e.preventDefault();

    if (!newPerson) {
      return;
    }

    onAddContact(newPerson);
    setNewPerson(null);
  };

  return (
    <form onSubmit={handleAddContact}>
      <div style={{ border: "1px solid lightgray", padding: "10px" }}>
        <h3>Add a new contact</h3>

        <label style={{ paddingRight: "10px" }}>
          Name&nbsp;
          <input
            type="text"
            value={newPerson?.name || ""}
            onChange={(e) =>
              setNewPerson({ ...newPerson, name: e.target.value })
            }
          />
        </label>

        <label style={{ paddingRight: "10px" }}>
          City&nbsp;
          <input
            type="text"
            value={newPerson?.city || ""}
            onChange={(e) =>
              setNewPerson({ ...newPerson, city: e.target.value })
            }
          />
        </label>

        <button type="submit">Add contact</button>
      </div>
    </form>
  );
};

export default AddContactForm;

Step 3: Displaying the contact details

Next, let’s create the actual person cards.

This will be rather straightforward - in the main file, we’ll now use the new component:

// contact-book.tsx
...
        <ul>
          {persons.map((person) => (
            <ContactCard key={person.name} person={person} />
          ))}
        </ul>
...

The card will just be a div:

// contact-card.tsx

import { Person } from "./Person";

const ContactCard = ({ person }: { person: Person }) => {
  return (
    <div>
      <h3>{person.name}</h3>
      <div>
        <em>City:&nbsp;</em>
        {person.city}
      </div>
    </div>
  );
};

export default ContactCard;

It’s a bit ugly, yes, but it does the trick :P

Screenshot2023-01-12at085603

Step 4: Edit button and unique ids

Now for the actually fun part of this exercise - let’s get each card to become editable!

First we’ll need to add an Edit button to the Card, to allow users to toggle editable mode.

import { Person } from "./Person";

type ContactCardProps = {
  person: Person;
  onEditContact: () => void;
};

const ContactCard: React.FC<ContactCardProps> = ({ person, onEditContact }) => {
  return (
    <div style={{ border: "1px solid lightblue", margin: "10px" }}>
      <h3>{person.name}</h3>
      <div>
        <em>City:&nbsp;</em>
        {person.city}
      </div>
      <div>
        <button onClick={onEditContact}>Edit</button>
      </div>
    </div>
  );
};

export default ContactCard;

Then we’ll need to somehow remember what person is currently in editable mode. Since we can only edit one person at a time, we can have an personBeingEdited variable to track this.

Next, we’ll need a way to uniquely identify each person. For now, we used the name, since it was enough for the first steps, but moving forward it’s better if we give each person its own id.

To do this, we can use the uuid library, which can generate unique ids for us.

First, we need to install it:

npm install uuid

Then, let’s update the TS type for the Person to also have an id:

export type Person = {
  id: string;
  name: string;
  city: string;
};

Last, let’s add the id property when a new person is created and save the person being edited when the user clicks “Edit”:

// contact-book.tsx
import { useState } from "react";
import AddContactForm from "./add-contact-form";
import ContactCard from "./contact-card";
import { Person } from "./Person";
import { v4 as uuidv4 } from "uuid";

const ContactBook = () => {
  const [persons, setPersons] = useState<Person[]>([]);
  const [personBeingEdited, setPersonBeingEdited] = useState<string | null>(
    null
  );

  const handleAddContact = (newPerson) => {
    setPersons([...persons, { id: uuidv4(), ...newPerson }]);
  };

  return (
    <div>
      <AddContactForm onAddContact={handleAddContact} />
      <div>
        {persons.map((person) => {
          if (personBeingEdited && person.id === personBeingEdited) {
            return (
              <>
                <div>This will be the form to edit {person.name}</div>
                <button onClick={() => setPersonBeingEdited(null)}>Save</button>
              </>
            );
          }
          return (
            <ContactCard
              key={person.id}
              person={person}
              onEditContact={() => setPersonBeingEdited(person.id)}
            />
          );
        })}
      </div>
    </div>
  );
};

export default ContactBook;

Step 5: Make the add form reusable

The last step is to setup the form for editing a person, so that the user can change a person’s name or city.

To do this, it would be great if we could reuse the form we used for adding a new person.

First, we can rename it from “AddContactForm” to just “ContactForm”.
Then, we can extract the things that are specific to the “Add” form as props.
Then, in the main app file, using the form will change from

<AddContactForm onAddContact={handleAddContact} />

to

<ContactForm title="Add a new contact" onSave={handleAddContact} />

Inside the form itself, we can rename the button text from “Add contact” to simply “Save” and rename the submit handler from “handleAddContact” to simply “handleSave”. The form state variable can also be renamed from “newPerson” to simply “person”.

// contact-form.tsx
import { useState } from "react";
import { Person } from "./Person";

const ContactForm = ({ onSave, title }) => {
  const [person, setPerson] = useState<Person>(null);

  const handleSave = (e) => {
    e.preventDefault();

    if (!person) {
      return;
    }

    onSave(person);
    setPerson(null);
  };

  return (
    <form onSubmit={handleSave}>
      <div style={{ border: "1px solid lightgray", padding: "10px" }}>
        <h3>{title}</h3>

        <label style={{ paddingRight: "10px" }}>
          Name&nbsp;
          <input
            type="text"
            value={person?.name || ""}
            onChange={(e) => setPerson({ ...person, name: e.target.value })}
          />
        </label>

        <label style={{ paddingRight: "10px" }}>
          City&nbsp;
          <input
            type="text"
            value={person?.city || ""}
            onChange={(e) => setPerson({ ...person, city: e.target.value })}
          />
        </label>

        <button type="submit">Save</button>
      </div>
    </form>
  );
};

export default ContactForm;

Step 6: Edit persons

Now we can use the renamed ContactForm to also edit a person:

import { useState } from "react";
import ContactForm from "./contact-form";
import ContactCard from "./contact-card";
import { Person } from "./Person";
import { v4 as uuidv4 } from "uuid";

const ContactBook = () => {
  const [persons, setPersons] = useState<Person[]>([]);
  const [personBeingEdited, setPersonBeingEdited] = useState<string | null>(
    null
  );

  const handleAddContact = (newPerson) => {
    setPersons([...persons, { id: uuidv4(), ...newPerson }]);
  };

  const handleEditContact = (updatedPerson) => {
    setPersons(
      persons.map((person) => {
        if (person.id === updatedPerson.id) {
          return updatedPerson;
        }
        return person;
      })
    );
    setPersonBeingEdited(null);
  };

  return (
    <div>
      <ContactForm title="Add a new contact" onSave={handleAddContact} />
      <div>
        {persons.map((person) => {
          if (personBeingEdited && person.id === personBeingEdited) {
            return (
              <ContactForm
                title="Edit contact"
                initialPerson={person}
                onSave={handleEditContact}
              />
            );
          }
          return (
            <ContactCard
              key={person.id}
              person={person}
              onEditContact={() => setPersonBeingEdited(person.id)}
            />
          );
        })}
      </div>
    </div>
  );
};

export default ContactBook;

Notice also that the ContactForm accepts an extra property - the initial value of the person. This is useful to initialise the form when editing.

// contact-form.tsx
const ContactForm = ({ onSave, title, initialPerson = null }) => {
  const [person, setPerson] = useState<Person>(initialPerson);
...

That’s a wrap!

How is this solution different from your own implementation?

Leave your thoughts or questions in the comments below.