Building this site #4: Sorting posts

While I'm on a roll I managed to implement another feature, which, while small, begins to take this site to new territory. So far everything here is quite conventional and inoffensive, but as I get better at using the tools I hope to make this site more and more "my own," as it were.

Something I've considered a lot is how to organize content: in many blogs I otherwise enjoy reading, sifting through older content is impossible. I don't have that many posts yet so having them all in one big list is fine for now; but the standard reverse-chronological stream, while OK most of the time, doesn't always do it for me. So what if we had the option of sorting posts on demand?

In the PostList component, which (unsurprisingly) renders a list of posts, we already have the infrastructure in place for a "side button" that goes into the list header.

export default function PostList({ heading, posts, sideButton }) {
  return (
    <>
      <SectionHead title={heading} sideButton={sideButton} />
      {posts.map(({ title, date, slug }) => {
        return (
          <Row className={styles.item} key={slug}>
            <Col md={2}>
              <span className="text-muted small">
                {moment(date).format("MMM D, YYYY")}
              </span>
            </Col>
            <Col>
              <Link href={`/${slug}`}>{title}</Link>
            </Col>
          </Row>
        )
      })}
    </>
  )
}

The header is in turn abstracted into its own component called SectionHead, which renders the button within a column on the opposite side of the same row as the header text.

export default function SectionHead({ title, sideButton }) {
  return (
    <>
      <Row className={styles.main}>
        <Col>
          <h2>{title}</h2>
        </Col>
        <Col xs="auto" className="d-flex align-items-center">
          {sideButton}
        </Col>
      </Row>
      <hr className="my-2"/>
    </>
  )
}

The button itself has to be supplied from outside these two components. After toying around with different options I decided that it should be not much of a button at all, but an icon that changes on click and cycles through a few different sorting options – in this case, reverse-chronological (default), chronological, and alphabetical. Here's what it looks like:

const options = {
  default: "/icons/sort-up.svg",
  chronological: "/icons/sort-down.svg",
  alphabetical: "/icons/sort-alpha-down.svg"
}

function getNextOption(order) {
  switch (order) {
    case "chronological": return "alphabetical"
    case "alphabetical": return "default"
    default: return "chronological"
  }
}

export default function SortButton({ order, setOrder }) {
  return (
    <Image className="clickable icon"
      src={options[order]}
      onClick={() => setOrder(getNextOption(order))}
    />
  )
}

This component takes in two props: order, which represents the current user-selected order, and setOrder, a callback that selects a new option (calculated by the getNextOption function). These props would ultimately have to be supplied by a custom hook that, at the same time, handles reordering our posts.

import { useState, useEffect } from "react"
import { by } from "../helpers/utils"

export default function useOrderedPosts(posts) {
  const [orderedPosts, setOrderedPosts] = useState(posts)
  const [order, setOrder] = useState("default")

  useEffect(() => {
    const storedOrder = window.localStorage.getItem("order")
    setOrder(storedOrder || "default")
  }, [])

  useEffect(() => {
    window.localStorage.setItem("order", order)

    switch (order) {
      case "alphabetical":
        setOrderedPosts([...posts].sort(by("title")))
        break
      case "chronological":
        setOrderedPosts([...posts].sort(by("date")))
        break
      default:
        setOrderedPosts([...posts].sort(by("date")).reverse())
    }
  }, [order])

  return { orderedPosts, order, setOrder }
}

useOrderedPosts accepts an initial array, posts. I decided to use localStorage to remember the last-selected order between sessions (if there is none, "default"), passing it into setOrder within the first useEffect. In the second useEffect, which fires whenever order is changed, the new value is saved to localStorage, after which setOrderedPosts is called with a new array, sorted according to the current selection.

Here the original array must be destructured as [...posts] before sorting so that setOrderedPosts knows we are actually passing in a new array; otherwise it won't work. And because I find sorting in JavaScript annoying, I also use a utility function that returns a custom comparator for easily sorting objects by a given property:

export function by(key) {
  return (a, b) => {
    return a[key] < b[key]
      ? -1
      : (a[key] > b[key]
        ? 1
        : 0)
  }
}

Finally, the hook returns orderedPosts, order, and setOrder, making them usable to the outside world. In this case the outside world is any component that might want to render an ordered list of posts. Notably, the blog page – here's what it now looks like with useOrderedPosts implemented:

export default function Blog({ posts, tags, about }) {
  const { orderedPosts, order, setOrder } = useOrderedPosts(posts)

  return (
    <Container className="page-container">
      <PageHead title="Blog" />
      <PageBody data={about} footer={(<TagGroup tags={tags} />)} />
      <br />
      <PostList
        heading={`${posts.length} Posts`}
        posts={orderedPosts}
        sideButton={(<SortButton order={order} setOrder={setOrder} />)}
      />
    </Container>
  )
}

Instead of passing the posts prop directly into PostList, we pass it into useOrderedPosts instead, giving us orderedPosts, which can then be used normally with the component, and all of the code that handles it is nicely tucked away in our custom hook. We also render an instance of SortButton, taking in the needed props, to pass into PostList as its sideButton. From there, the button will be able to read and set a new order on demand.

I also did the same with the Tag page:

export default function TagPage({ tag, posts }) {
  const { orderedPosts, order, setOrder } = useOrderedPosts(posts)
  const title = `Tag: '${tag.label}'`

  return (
    <Container className="page-container">
      <PageHead title={title} />
      <PageBody data={{ title }} />
      <PostList
        heading={`${posts.length} ${posts.length > 1 ? "Posts" : "Post"}`}
        posts={orderedPosts}
        sideButton={(<SortButton order={order} setOrder={setOrder} />)}
      />
    </Container>
  )
}

And that's done. With just a little work and fun with React hooks, we are no longer so hopelessly tied to the reverse-chronological stream.