DEV Community

Cover image for Build address search component in React
Phan Công Thắng
Phan Công Thắng

Posted on • Updated on <time datetime="2021-09-29T08:01:17Z" class="date-no-year">Sep 29</time> • Originally published at thangphan.xyz

Build address search component in React

In this post, I'm going to build an address search component using React.

Requirements

In the component, I have a postcode value, and every time I click the search button, I can get the address.

It will have two cases of the result:

  1. Return one address.
  2. Return many addresses.

If the result is one address, I will set the value for the addresses inputs(disabled) otherwise I have to show the address list for the user. When the user selects an address from the address list, the selected address will be showed in the address's inputs.

Thinking in React

Components

My component will have 4 child components:

  1. PostCode.
  2. Search.
  3. Addresses.
  4. Address.

State

This is some states I need to have for my components:

  1. postCode for PostCode.
  2. addresses for Addresses.
  3. address for Address.
  4. isOpen for toggling the Addresses component.

Implement

Address Implementation

Coding

I will use Context in order to avoid props drilling in React.

  • Search component need to use these state: postCode, address, addresses, isOpen.
  • Addresses component need to use state: address.

So I will have the Providers like below:

<AddressProvider>
  <AddressesProvider>
    <IsOpenProvider>
      <PostCodeProvider>
        <PostCode />
        <Search />
      </PostCodeProvider>
      <Addresses />
    </IsOpenProvider>
  </AddressesProvider>
  <Address />
</AddressProvider>
Enter fullscreen mode Exit fullscreen mode

I created a function that helps me for generating the context and a hook to consume that context.

function createContext(name: string) {
  const context = React.createContext(null)

  function useContext() {
    const contextValue = React.useContext(context)

    if (contextValue === null) {
      throw new Error(`use${name} must be used within ${name}Provider`)
    }

    return contextValue
  }

  return {Context: context, useContext}
}
Enter fullscreen mode Exit fullscreen mode

Now, I will create the provider above:


type IsOpenContextType = {
  isOpen: boolean
  setIsOpen: React.Dispatch<React.SetStateAction<boolean>>
}
const {Context: IsOpenContext, useContext: useIsOpen} =
  createContext<IsOpenContextType>('IsOpen')

function IsOpenProvider({children}: {children: React.ReactNode}) {
  const [isOpen, setIsOpen] = React.useState(false)
  const value = React.useMemo(() => ({isOpen, setIsOpen}), [isOpen, setIsOpen])
  return (
    <IsOpenContext.Provider value={value}>{children}</IsOpenContext.Provider>
  )
}

type Address = {
  code: number
  prefecture: string
  city: string
  ward: string
}
type AddressContextType = {
  address: Address | null
  setAddress: React.Dispatch<React.SetStateAction<Address | null>>
}
const {Context: AddressContext, useContext: useAddress} =
  createContext<AddressContextType>('Address')

function AddressProvider({children}: {children: React.ReactNode}) {
  const [address, setAddress] = React.useState<Address | null>(null)
  const value = React.useMemo(
    () => ({address, setAddress}),
    [address, setAddress],
  )

  return (
    <AddressContext.Provider value={value}>{children}</AddressContext.Provider>
  )
}

type AddressesContextType = {
  addresses: Array<Address> | null
  setAddresses: React.Dispatch<React.SetStateAction<Array<Address> | null>>
}

const {Context: AddressesContext, useContext: useAddresses} =
  createContext<AddressesContextType>('Addresses')
function AddressesProvider({children}: {children: React.ReactNode}) {
  const [addresses, setAddresses] = React.useState<Array<Address> | null>(null)
  const value = React.useMemo(
    () => ({addresses, setAddresses}),
    [addresses, setAddresses],
  )

  return (
    <AddressesContext.Provider value={value}>
      {children}
    </AddressesContext.Provider>
  )
}
type PostCodeContextType = {
  postCode: Array<string>
  setPostCode: React.Dispatch<React.SetStateAction<Array<string>>>
}

const {Context: PostCodeContext, useContext: usePostCode} =
  createContext<PostCodeContextType>('PostCode')
function PostCodeProvider({children}: {children: React.ReactNode}) {
  const [postCode, setPostCode] = React.useState(() =>
    Array.from({length: 2}, () => ''),
  )

  const value = React.useMemo(
    () => ({postCode, setPostCode}),
    [postCode, setPostCode],
  )

  return (
    <PostCodeContext.Provider value={value}>
      {children}
    </PostCodeContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

PostCode component:


function PostCode() {
  const {postCode, setPostCode} = usePostCode()

  function hanldePostCodeChange(
    event: React.ChangeEvent<HTMLInputElement>,
    idx: number,
  ) {
    const newPostCode = [...postCode]
    newPostCode.splice(idx, 1, event.target.value)
    setPostCode(newPostCode)
  }
  return (
    <div>
      <input onChange={(e) => hanldePostCodeChange(e, 0)} />
      <input onChange={(e) => hanldePostCodeChange(e, 1)} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

When I click the search button, I have to fake an API for it. I will use msw, and create a fake API.

rest.get('/addresses', (req, res, ctx) => {
  return res(
    ctx.delay(3000),
    ctx.status(200),
    ctx.json({
      data: [
        {
          code: 13,
          prefecture: 'Tokyo',
          city: 'Otaku',
          ward: 'Kamata',
        },
        {
          code: 12,
          prefecture: 'Osaka',
          city: 'Namba',
          ward: 'Suidou',
        },
      ],
    }),
  )
}),
Enter fullscreen mode Exit fullscreen mode

Search component:


function Search() {
  const {setAddress} = useAddress()
  const {postCode} = usePostCode()
  const {setAddresses} = useAddresses()
  const {setIsOpen} = useIsOpen()

  async function handleAddressesSearch() {
    const query = postCode.every((pc) => Boolean(pc)) ? postCode.join('-') : ''
    if (!query) return

    const res = await fetch(`addresses?postCode=${query}`)
    const resJson = await res.json()

    if (resJson.data.length > 1) {
      setIsOpen(true)
      setAddresses(resJson.data)
    } else {
      setAddress(resJson.data[0])
    }
  }
  return <button onClick={handleAddressesSearch}>Search</button>
}
Enter fullscreen mode Exit fullscreen mode

Addresses component:


function Addresses() {
  const {addresses} = useAddresses()
  const {setAddress} = useAddress()
  const {isOpen, setIsOpen} = useIsOpen()

  function handleAddressSelect(address: Address) {
    setIsOpen(false)
    setAddress(address)
  }

  if (!isOpen) return null
  return (
    <ul>
      {addresses?.map((ad, _idx) => (
        <li
          key={`addresses-items-${_idx}`}
          onClick={() => handleAddressSelect(ad)}
        >
          {ad.prefecture},{ad.city}, {ad.ward}
        </li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

Address component:


function Address() {
  const {address, setAddress} = useAddress()

  function handleWardChange(event: React.ChangeEvent<HTMLInputElement>) {
    setAddress({
      ...address,
      ward: event.target.value,
    })
  }

  return (
    <div>
      <input value={address?.code ?? ''} disabled />
      <input value={address?.prefecture ?? ''} disabled />
      <input value={address?.city ?? ''} disabled />
      <input value={address?.ward ?? ''} onChange={handleWardChange} />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

And combine all of the components to my page:

function AutoAddress() {
  return (
    <AddressProvider>
      <AddressesProvider>
        <IsOpenProvider>
          <PostCodeProvider>
            <PostCode />
            <Search />
          </PostCodeProvider>
          <Addresses />
        </IsOpenProvider>
      </AddressesProvider>
      <Address />
    </AddressProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Let's test my app with two cases:

  • Result with one address:

One Address

  • Result with many addresses:

Many Addresses

Improvement

My component worked as I expected, but it has a problem is every time I click the button search, I have to re-fetch API. It will be better if I can cache the addresses in case the postCode wasn't changed.

And swr is very helpful in this case.

  1. I need to have a flag wasSearched to make sure calling API only happens when I click the button search.
  2. When I pass query of postCode to useSWR, useSWR will automatically determine the value of postCode changed or not.
  3. I have to check if postCode wasn't changed for two cases(one address or many addresses), and do some stuff with each case.

Let's create WasSearchedProvider:


type WasSearchedContextType = {
  wasSearched: boolean
  setWasSearched: React.Dispatch<React.SetStateAction<boolean>>
}
const {Context: WasSearchedContext, useContext: useWasSearched} =
  createContext<WasSearchedContextType>('WasSearched')

function WasSearchedProvider({children}: {children: React.ReactNode}) {
  const [wasSearched, setWasSearched] = React.useState(false)
  const value = React.useMemo(
    () => ({wasSearched, setWasSearched}),
    [wasSearched, setWasSearched],
  )
  return (
    <WasSearchedContext.Provider value={value}>
      {children}
    </WasSearchedContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Change Addresses component code to using swr:

function AddressesProvider({children}: {children: React.ReactNode}) {
  const {wasSearched} = useWasSearched()
  const {postCode} = usePostCode()
  const {setIsOpen} = useIsOpen()
  const {address, setAddress} = useAddress()
  const query = postCode.every((pc) => Boolean(pc)) ? postCode.join('-') : ''

  const {data: addresses, error} = useSWR(
    wasSearched ? `addresses?postCode=${query}` : null,
    (arg: string) =>
      fetch(arg)
        .then((r) => r.json())
        .then((res) => {
          if (res?.data.length === 1) {
            const {code, city, prefecture, ward} = res.data[0]
            setAddress({
              ...address,
              code,
              city,
              prefecture,
              ward,
            })
          }

          return res?.data
        }),
  )

  useDeepCompareEffect(() => {
    if (!addresses) return

    if (addresses.length > 1) {
      setIsOpen(true)
    }
  }, [{addresses}])

  const value = React.useMemo(() => ({addresses, error}), [addresses, error])

  return (
    <AddressesContext.Provider value={value}>
      {children}
    </AddressesContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode
  1. If the response is one address, I will set the state for address immediately.
  2. If the response is many addresses, I will have to wait for the addresses state to be set, then trigger a compare between the current state and previous state using useDeepCompareEffect. If it is different, I will change isOpen from false -> true.

Addresses component also need to access these states: postCode, wasSearched, isOpen, setAddress.

Let's change the providers:

function AutoAddress() {
  return (
    <AddressProvider>
      <WasSearchedProvider>
        <PostCodeProvider>
          <IsOpenProvider>
            <AddressesProvider>
              <PostCode />
              <Search />
              <Addresses />
            </AddressesProvider>
          </IsOpenProvider>
        </PostCodeProvider>
      </WasSearchedProvider>
      <Address />
    </AddressProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Change the logic in Search component:


function TwoArrayStringIsEqual(a: Array<string>, b: Array<string>) {
  return a.every((str, idx) => str === b[idx])
}

function usePrevious<T>(value: T) {
  const ref = React.useRef(value)

  React.useEffect(() => {
    ref.current = value
  })

  return ref.current
}

function Search() {
  const {address, setAddress} = useAddress()
  const {postCode} = usePostCode()
  const previousPostCode = usePrevious<Array<string>>(postCode)
  const {addresses} = useAddresses()
  const {setWasSearched} = useWasSearched()
  const {setIsOpen} = useIsOpen()

  async function handleAddressesSearch() {
    setWasSearched(true)

    if (addresses && TwoArrayStringIsEqual(previousPostCode, postCode)) {
      if (addresses.length === 1) {
        const {code, city, prefecture, ward} = addresses[0]
        setAddress({
          ...address,
          code,
          city,
          prefecture,
          ward,
        })
      } else {
        setIsOpen(true)
      }
    }
  }
  return <button onClick={handleAddressesSearch}>Search</button>
}
Enter fullscreen mode Exit fullscreen mode

When I change the postCode input, It will call API, because the value of postCode was changed. So I have to reset wasSearched to false.

function hanldePostCodeChange(
  event: React.ChangeEvent<HTMLInputElement>,
  idx: number,
) {
  if (wasSearched) {
    setWasSearched(false)
  }
  const newPostCode = [...postCode]
  newPostCode.splice(idx, 1, event.target.value)
  setPostCode(newPostCode)
}
Enter fullscreen mode Exit fullscreen mode

Now, I can see the address list immediately and don't need to trigger a fetch request.

Addresses Caching

Change the postCode and re-fetch API:

PostCode Changed

Conclusion

I have just built an address component using React, and improve the performance by using swr. Please feel free to refer to the source code.

Discussion (1)

Collapse
victorocna profile image
Victor Ocnarescu

Cool article! I would definitely use React state instead of context. Prop drilling is not so bad (and potentially non existent) for well structured components.

The only times I use context is for theme options, language options or any app wide configurations, just like the React docs mention. Cheers!