Watch a live product demo 👀 See how Sanity powers richer commerce experiences

How to Calculate the Reading Time of Body Content with React

17 replies
Last updated: Apr 5, 2022
Hi all! Trying to implement the "react-hook-reading-time" to calculate the reading time of the body content. But the problem is it's trying to calculate the body text. But the PortableText body is one array of multiple objects. Is it possible to add this? Can I like filter out the text only from the body array?
Apr 4, 2022, 2:17 PM
I have simply add a field with title " reading time " and put reading time myself 😄 if it's help 🙂You have also a reading time calculator here
https://niram.org/read/
Apr 4, 2022, 2:41 PM
    {
      name: "read",
      title: "read",
      type: "number",
      description: "put number in minutes for reading time👇",
      validation: (Rule) => Rule.required(),
    },
Apr 4, 2022, 2:42 PM
Hey
user Q
, I’ve made a little custom component that might help!
Schema:

import RenderReadingTime from "./RenderReadingTime";

export default {
    name: "page",
    title: "Pages",
    type: "document",
    fields: [
        {
            title: "Content",
            name: "content",
            type: "array",
            of: [{ type: "block" }],
        },
        {
            name: "reading",
            title: "Reading Time",
            type: "string",
            inputComponent: RenderReadingTime,
        },
    ],
};
Custom component:

import React from "react";
import { Card, Stack, Inline, Badge } from "@sanity/ui";
import { FormField } from "@sanity/base/components";
import { withDocument } from "part:@sanity/form-builder";
import { useReadingTime } from "react-hook-reading-time";

export const RenderReadingTime = React.forwardRef((props, ref) => {
    const { type, document } = props;

    const filteredBlock = document.content
        .filter((x) => x._type === "block")
        .map((children) => children.children.map((texts) => texts.text));

    const readingTime = useReadingTime(filteredBlock.join(" "));

    return (
        <FormField description={type.description} title={type.title}>
            <Card padding={[3, 3, 3, 3]} shadow={1} radius={2} ref={ref}>
                <Stack space={[3, 3, 3]}>
                    <Inline space={2}>
                        <Badge tone={"primary"}>{readingTime.text}</Badge>
                        <Badge>{`${readingTime.words} words`}</Badge>
                    </Inline>
                </Stack>
            </Card>
        </FormField>
    );
});

export default withDocument(RenderReadingTime);
The styling is quite basic, but hopefully this works for you.
Apr 4, 2022, 3:44 PM
Tried doing it dynamically why over complicate things. Thanks!
Apr 4, 2022, 3:45 PM
You’re welcome!
Apr 4, 2022, 4:02 PM
user P
Got it working. but inside GROQ I get null if "reading" is added to the query.
Apr 5, 2022, 8:28 AM
export const articleQuery = groq`*[_type == "article" && slug.current == $slug][0] {
    ...,
    reading,
    body[] {
    ...,
    _type == "imageWithAltText" => {
        asset -> {
          ...,
          url,
        },
    },

    _type == "video" => {
        asset -> {
          ...,
          videoPlatform,
          href,
        },
    },

    markDefs[] {
      ...,
      _type == "internalLink" => {
        "path": reference->slug.current
      }
    },
  },


  'comments': *[
    _type == "comment" &&
    article._ref == ^._id &&
    approved == true
  ],

  user -> {
    name,
    featuredImage,
  },


  "slug": slug.current,
}`;
Apr 5, 2022, 8:28 AM
is not
reading->,
?
export const articleQuery = groq`*[_type == "article" && slug.current == $slug][0] {
    ...,
    reading->,
    body[] {
    ...,
    _type == "imageWithAltText" => {
        asset -> {
          ...,
          url,
        },
    },

    _type == "video" => {
        asset -> {
          ...,
          videoPlatform,
          href,
        },
    },

    markDefs[] {
      ...,
      _type == "internalLink" => {
        "path": reference->slug.current
      }
    },
  },


  'comments': *[
    _type == "comment" &&
    article._ref == ^._id &&
    approved == true
  ],

  user -> {
    name,
    featuredImage,
  },


  "slug": slug.current,
}`;
Apr 5, 2022, 9:41 AM
Returns null
Apr 5, 2022, 9:44 AM
user Q
At the moment the ‘reading’ component is just a preview in the studio – it’s not patching the data into the schema. You’ll need to add the
PatchEvent
from
"@sanity/form-builder/PatchEvent"
into the RenderReadingTime component. I will try and update the component a bit later today!
Apr 5, 2022, 9:48 AM
user P
Thank you very much would be nice to have it inside the GROQ instead of having to write same code on the front-end side
Apr 5, 2022, 9:57 AM
user P

This is what I have now but GROQ returns 1d.


import React from "react";
import { Card, Stack, Inline, Badge, TextInput } from "@sanity/ui";
import { FormField } from "@sanity/base/components";
import { withDocument } from "part:@sanity/form-builder";
import { useReadingTime } from "react-hook-reading-time";
import { useId } from "@reach/auto-id"; // hook to generate unique IDs

import PatchEvent, { set, unset } from "@sanity/form-builder/PatchEvent";

export const RenderReadingTime = React.forwardRef((props, ref) => {
  const {
    type,
    document,
    value,
    readOnly,
    onChange,
    compareValue,
    markers,
    presence,
  } = props;

  const filteredBlock = document.body
    .filter((x) => x._type === "block")
    .map((children) => children.children.map((texts) => texts.text));

  const readingTime = useReadingTime(filteredBlock.join(" "));

  // Creates a unique ID for our input
  const inputId = useId();

  // Creates a change handler for patching data
  const handleChange = React.useCallback(
    // useCallback will help with performance
    (event) => {
      const inputValue = event.currentTarget.value; // get current value
      // if the value exists, set the data, if not, unset the data
      onChange(PatchEvent.from(inputValue ? set(inputValue) : unset()));
    },
    []
  );

  return (
    <FormField
      description={type.description}
      title={type.title}
      compareValue={compareValue}
      inputId={inputId}
      __unstable_markers={markers}
      __unstable_presence={presence}
    >
      <Card padding={[3, 3, 3, 3]} shadow={1} radius={2} ref={ref}>
        <Stack space={[3, 3, 3]}>
          <Inline space={2}>
            <Badge tone="primary">{readingTime.text}</Badge>
            <Badge>{`${readingTime.words} words`}</Badge>
            <TextInput
              id={inputId}
              onChange={handleChange}
              value={readingTime.minutes || ""}
              readOnly={readOnly}
              placeholder={readingTime.minutes}
              ref={ref}
            />
          </Inline>
        </Stack>
      </Card>
    </FormField>
  );
});

export default withDocument(RenderReadingTime);
Apr 5, 2022, 10:13 AM
I’ve updated the component to patch the resulting reading time into the schema (as well as showing a preview in the studio).
Although this works, I’m unsure if I’m following the best practice in terms of patching data! I ended up using the
sanityClient
rather than
PatchEvent
as I believe PatchEvent requires an onChange event to trigger, and as the component doesn’t have a direct user input, this isn’t possible (correct me if I’m wrong).
The reading time component supports both
string
and
block
types, and requires the options of
fieldName
(which field that will be patched, which is probably is the same field) & `target`:

{
    title: "Content",
    name: "content",
    type: "array",
    of: [{ type: "block" }],
},
{
    name: "reading",
    title: "Reading Time",
    type: "string",
    inputComponent: RenderReadingTime,
    options: { fieldName: "reading", target: "content" }
},
Here is the component:

import React, { useState, useMemo, useEffect } from "react";
import { Card, Stack, Inline, Badge } from "@sanity/ui";
import { FormField } from "@sanity/base/components";
import { withDocument } from "part:@sanity/form-builder";
import { useReadingTime } from "react-hook-reading-time";
import sanityClient from "part:@sanity/base/client";
const client = sanityClient.withConfig({ apiVersion: "2022-02-15" });

export const RenderReadingTime = React.forwardRef((props, ref) => {
    const { type, document } = props;
    const [time, setTime] = useState(null);

    const valid = useMemo(() => {
        const validate =
            (type.options?.target !== undefined &&
                document[type.options?.target] !== undefined &&
                document[type.options?.fieldName] !== undefined &&
                Array.isArray(document[type.options?.target])) ||
            Object.prototype.toString.call(document[type.options?.target]) === "[object String]";
        
        const targetType = validate
            ? Array.isArray(document[type.options?.target])
                ? "block"
                : "string"
            : false;
        
        return targetType;
    }, []);

    useEffect(() => {
        client
            .patch(document._id)
            .set({ reading: time?.words && valid !== false ? time.text : "" })
            .commit()
            .catch((err) => console.error("The update to 'reading' failed: ", err.message));
    }, [time]);

    return (
        <FormField description={type.description} title={type.title}>
            <Card padding={[3, 3, 3, 3]} shadow={1} radius={2} ref={ref}>
                <Stack space={[3, 3, 3]}>
                    {valid !== false ? (
                        <RenderReadingTimePatch
                            document={document}
                            type={type}
                            valid={valid}
                            time={time}
                            setTime={setTime}
                        />
                    ) : (
                        <Inline space={2}>
                            <Badge tone={"critical"}>Invalid or missing target/fieldName</Badge>
                        </Inline>
                    )}
                </Stack>
            </Card>
        </FormField>
    );
});

const RenderReadingTimePatch = ({ document, type, valid, time, setTime }) => {
    const debouncedInput = useDebounce(document[type.options.target], 1000);

    useEffect(() => {
        if (valid === "block") {
            const filteredBlock = document[type.options.target]
                .filter((x) => x._type === "block")
                .map((children) => children.children.map((texts) => texts.text));
            setTime(useReadingTime(filteredBlock.join(" ")));
        } else {
            setTime(useReadingTime(useReadingTime(document[type.options.target])));
        }
    }, [debouncedInput]);

    return (
        <div>
            {time && (
                <Inline space={2}>
                    <Badge tone={"primary"}>{time.text}</Badge>
                    <Badge>{`${time.words} words`}</Badge>
                </Inline>
            )}
        </div>
    );
};

const useDebounce = (value, delay) => {
    const [debouncedValue, setDebouncedValue] = useState(value);
    useEffect(() => {
        const handler = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        return () => {
            clearTimeout(handler);
        };
    }, [value, delay]);
    return debouncedValue;
};

export default withDocument(RenderReadingTime);
Apr 5, 2022, 1:21 PM
user P
Great! Which setState value has to be changed to only return "minutes" as a number instead of complete string? Because I am need the minutes only this way I can apply my own JSX.
Apr 5, 2022, 1:52 PM
You can change the section below from
time.text
to
time.minutes.toString()


    useEffect(() => {
        client
            .patch(document._id)
            .set({ reading: time?.words && valid !== false ? time.text : "" }) //CHANGE HERE
            .commit()
            .catch((err) => console.error("The update to 'reading' failed: ", err.message));
    }, [time]);
Apr 5, 2022, 1:57 PM
So
.set({ reading: time?.words && valid !== false ? time.text : "" })
becomes
.set({ reading: time?.words && valid !== false ? time.minutes.toString() : "" })
Apr 5, 2022, 1:58 PM
Applied time.minutes but missed the toString() function. Thank you Simon!
Apr 5, 2022, 1:59 PM

Sanity– build remarkable experiences at scale

The Sanity Composable Content Cloud is the modern headless CMS that treats content as data to power your digital business. Free to get started, and pay-as-you-go on all plans.

Related answers

Get more help in the community Slack

TopicCategoriesFeaturedRepliesLast Updated
After adding the subtitle and running this code npm run graphql-deploy It does nothingSep 15, 2020
how to limit a reference to just one entry in Studio reference input side versus the default as-many-entries-as-you-fill-in-an-array...Sep 18, 2020
Is it possible to fetch more than one "_type" using GROQ?Nov 2, 2020
I want to add a view with the Structure builder (S.view.component) where I list similar documents based on the title. What...Sep 23, 2020
Is there a structure builder example where the format of each preview for the document list is modified?Feb 3, 2021
I have an array of references to a country schema type but it always just returns NULL values for meJan 30, 2021
Hi, I need help with a query for getting the url of an image asset. Here is what I've been trying, but I only get the _ref...Dec 1, 2020
Sanity UI looks brilliant :smiley: Is something like the current date picker possible at the moment? I’m not sure if anicon...Dec 21, 2020
Hey everyone. I have been coding and may have potentially accidentally deleted something. Does anyone know how to resolve...Dec 26, 2020
Hello everyone and happy new year :raised_hands::skin-tone-2:, I have a problem with outputting Portable Text :disappointed:...Jan 1, 2021

Related contributions

Clean Next.js + Sanity app
- Template

Official(made by Sanity team)

A clean example of Next.js with embedded Sanity ready for recomposition.

Cody Olsen
Go to Clean Next.js + Sanity app

Blog with Built-in Content Editing
- Template

Official(made by Sanity team)

A Sanity-powered blog with built-in content editing and instant previews.

Go to Blog with Built-in Content Editing