Fragmented Thought

Creating dynamic state using state in React

React icon
By

Published:

Lance Gliser

Heads up! This content is more than six months old. Take some time to verify everything still works as expected.

We came on an interesting problem this week. The services team needed to define the fields that could be indexed, and how, dynamically based on customer supplied ontologies. None of us can know what can be searched, or how, about what. We're still going to produce an advanced search that gives field specific search controls. Woo! Sounds like fun. Credit goes to Liz Townsend for our original approach, which I've expanded on here.

If you like you can skip the specifics, and go straight to the React implementation.

API contract

After some basic conversations we came to the agreement they could craft an API capable of sending field schemas. The basic idea was to send an array of fields, and we'd send back an array of values paired with those field ids. We landed on this schema. Some liberties have been taken to abstract from the real usage. Perhaps the full schema is overkill for understanding the problem, hopefully someone out there gets enjoys it just the same.

GraphQL schema

# See https://github.com/taion/graphql-type-json # It allows us to send anything we need back and forth without validation scalar JSON extend type Query { "Returns the fields a user may search by" searchFields: [FieldSchema!] "Returns objects that match your search criteria" search( "A string of text from the user to be used in full text mode against all indexed data" query: String "A flexible set of values based on the searchFields used to make more precise searching" filters: [FieldSchemaInput!] "The number of records in this set" limit: Int "The index of the first item in this result set from the larger collection" offset: Int ): ObjectsPagedResponse } enum DataType { String Number Date Coordinates } "Defines schema meta data about a field" type FieldSchema { type: DataType! "An id used to match up to the values : i.e. AWSRegion" fieldId: ID! "A preformatted display name safe to display in HTML context : AWS Region" displayName: String! "Defines if this field is required" required: Boolean "A set of options to be used for limited choice options - Ex: [us-east-1, us-west]" options: [FieldSchemaOption!] "Allows the selection of multiple options" multiple: Boolean } type FieldSchemaOption { value: JSON! "A preformatted display name safe to display in HTML context : AWS Region" displayName: String! } input FieldSchemaInput { "An id used to match up to the values : i.e. AWSRegion" fieldId: ID! "The current value of this schema" value: JSON! }

To provide a bit more clarity, I've generated some examples of usages:

Example searchFields response

[ { "type": "String", "fieldId": "document.classification.number", "displayName": "Classification number", "required": null, "options": null, "multiple": null }, { "type": "String", "fieldId": "document.revision.comment", "displayName": "Revision comment", "required": null, "options": null, "multiple": null } ]

Example search query

{ "operationName": "Page", "variables": { "query": "ownership", "filters": [ { "fieldId": "document.classification.number", "value": "1234" }, { "fieldId": "document.revision.comment", "value": "Jane" } ] }, "query": "query Page($query: String, $filters: [FieldSchemaInput!], $limit: Int, $offset: Int) {\n search(query: $query, filters: $filters, limit: $limit, offset: $offset) {\n limit\n offset\n total\n items { ... }\n}\n" }

React implementation

This will be broken into two parts. My goal in implementing this functionality was to abstract away from the display component everything we could about state management. For EntityField, value, and setValue the props should be dirt simple, just the return of const [value, useValue] = useState("").

At a high level the separate of concerns would then look like this:

SchemaFields.tsx would provide props that define the shape, and value handling like a basic input field:

export interface SchemaFieldProps<T = string> { value: T; setValue: Dispatch<SetStateAction<T>>; schema: FieldSchema; fieldProps?: TextFieldProps; /** Provides a method to override the simple MenuItem rendering of the select if required */ children?: ReactNode | undefined; }

SearchForm.tsx would then responsible for keeping state that could provide state that could match the value and setValue handling by id. It can be expressed in Typescript this way:

type FieldValue = string; type FieldsState = Record< FieldSchema["fieldId"], // Just a string [ // The value property FieldValue | undefined, // The setValue property Dispatch<SetStateAction<FieldValue | undefined>> ] >;

With FieldsState in mind, we can declare our state this way.

// Start with an empty object, we'll have to remap our API results into it // after the component mounts. const [fieldsState, setFieldsState] = useState < FieldsState > {};

Through whatever mechanism you make your API call, the resulting EntityFields array must be used to update our fieldsState:

const fields = // Fields, no fields? Who cares, make an array and we'll reduce it (fieldSchemasQuery.data?.searchFields ?? []) // Each pass of the reducer we'll add a new object key consisting // of a `value` and function handles like `setValue`. .reduce((fields, schema) => { fields[schema.fieldId] = [ // Just our initial value, in this example it's safe for me to use a string. // Your miliage will vary, and might need more complex defaults like: // schema.type === "Number" ? 0 : "" handling "", // This was the yummy part for me. We have to return a setState mimic. // (Shout out to Neven Iliev's mimic! I ā¤ļø your murder hobo. It's delightful guilty pleasure.) // We'll need to take a value (which could be the actual value, or a function) (value) => // We're going return our own function wrapping the behavior of the // standard `setValue` behavior. setFieldsState((prevState) => { // We can get the internal `setValue` behavior from state const [, setValue] = prevState[schema.fieldId]; // At the time, I didn't need to support dispatch `setValue` internals. // This logic would expand if you did. if (typeof value === "function") { throw new Error( "This pattern does not currently support functions" ); } return { ...prevState, // We can append the new value, along with our // wrapped `setValue` to added to our dynamic state. [schema.fieldId]: [value, setValue], }; }), ]; return fields; }, {} as FieldsState); setFieldsState(fields);

At that point you'll be able to loop your new dynamic fieldsState to:

Render the entity fields:

<> {fieldSchemasQuery.data?.searchFields // The filter is just to solve TS issues caused // by Maybe<string> | undefined nature of SchemaField.fieldId ?.filter((schema) => fieldsState[schema.fieldId]) ?.map((schema) => { const { fieldId, type } = schema; // Here's that usage we were hoping for. // Dirt simple, and delicious. const [value, setValue] = fieldsState[fieldId]; return ( <FieldContainer key={fieldId}> <SchemaField schema={{ ...schema, type: getDisplayTypeFromDataType(type), }} value={value} setValue={setValue} fieldProps={{ type: "search", inputProps: { name: fieldId, }, }} /> </FieldContainer> ); })} </>

Collect the field values and submit to search:

const onSubmit: FormEventHandler = (event) => { event.preventDefault(); // However your search works, as long as it accepts // an array of { value: unknown, fieldId: string} like filters // in this example navigateToSearch(navigate, { // A class state usage query: query, // Our dynamic fields filters: Object.keys(fieldsState) .filter((fieldId) => Boolean(fieldsState[fieldId][0])) .map( (fieldId) => ({ fieldId, value: fieldsState[fieldId][0], }), {} as InputMaybe<Array<FieldSchemaInput>> ), }); };

I've included some examples of the a SearchForm and EntityField I used below. As with most of my current work, it relies on Material UI 4.x.

SearchForm.tsx example

import React, { Dispatch, FormEventHandler, SetStateAction, useEffect, useState, } from "react"; import { useNavigate } from "react-router-dom"; import { navigateToSearch } from "../../../../utils/search"; import { DataType, FieldSchema, useSearchFieldsQuery, } from "../../../../generated/types"; import { Box, Button, TextField } from "@material-ui/core"; import { Skeleton } from "@material-ui/lab"; import SchemaField, { SchemaFieldProps, } from "./SchemaField/SchemaField"; interface Props {} const SearchForm: React.FunctionComponent<Props> = () => { const navigate = useNavigate(); const [query, setQuery] = useState(""); const fieldSchemasQuery = useSearchFieldsQuery(); const [fieldsState, setFieldsState] = useState<FieldsState>({}); useEffect(() => { const fields = ( fieldSchemasQuery.data?.searchFields ?? [] ).reduce((fields, schema) => { fields[schema.fieldId] = [ "", (value) => setFieldsState((prevState) => { const [, setValue] = prevState[schema.fieldId]; if (typeof value === "function") { throw new Error( "This pattern does not currently support functions" ); } return { ...prevState, [schema.fieldId]: [value, setValue], }; }), ]; return fields; }, {} as FieldsState); setFieldsState(fields); }, [fieldSchemasQuery.data]); const onSubmit: FormEventHandler = (event) => { event.preventDefault(); navigateToSearch(navigate, { query: query, filters: Object.keys(fieldsState) .filter((fieldId) => Boolean(fieldsState[fieldId][0])) .map( (fieldId) => ({ fieldId, value: fieldsState[fieldId][0], }), {} as InputMaybe<Array<FieldSchemaInput>> ), }); reset(); }; const reset = () => { setQuery(""); setFieldsState((prevState) => Object.keys(prevState).reduce((fieldsState, fieldId) => { const [, setValue] = prevState[fieldId]; fieldsState[fieldId] = ["", setValue]; return fieldsState; }, {} as FieldsState) ); }; return ( <form onSubmit={onSubmit}> <TextField autoFocus value={query} onChange={(event) => setQuery(event.target.value)} /> {fieldSchemasQuery.loading && new Array(3).fill(0).map((_, index) => ( <FieldContainer key={index}> <Skeleton variant="rect" height={60} /> </FieldContainer> ))} {fieldSchemasQuery.data?.searchFields ?.filter((schema) => fieldsState[schema.fieldId]) ?.map((schema) => { const { fieldId, type } = schema; const [value, setValue] = fieldsState[fieldId]; return ( <FieldContainer key={fieldId}> <SchemaField schema={{ ...schema, type: getDisplayTypeFromDataType(type), }} value={value} setValue={setValue} fieldProps={{ type: "search", inputProps: { name: fieldId, }, }} /> </FieldContainer> ); })} <Button variant={"contained"} color={"primary"} type={"submit"}> Page </Button> </form> ); }; export default SearchForm; const FieldContainer: React.FunctionComponent = ({ children }) => ( <Box my={2}>{children}</Box> ); const getDisplayTypeFromDataType = ( type: FieldSchema["type"] ): SchemaFieldProps["schema"]["type"] => { switch (type) { case DataType.String: return "string"; case DataType.Number: return "number"; default: throw new Error(`Unhandled FieldSchema["type"]: ${type}`); } }; type FieldValue = string; type FieldsState = Record< FieldSchema["fieldId"], [FieldValue | undefined, Dispatch<SetStateAction<FieldValue | undefined>>] >;

EntityField.tsx example

Disclaimer: This component uses React function components with Typescript Generics. Make sure to look up that syntax if you're new to it.

import React, { Dispatch, ReactElement, ReactNode, SetStateAction, } from "react"; import { MenuItem } from "@material-ui/core"; import { Maybe } from "graphql/jsutils/Maybe"; import Select, { getSelectChangeHandler } from "../Select/Select"; import TextField, { getTextFieldChangeHandler, TextFieldProps, } from "../TextField/TextField"; export interface SchemaFieldProps<T = string> { value: T; setValue: Dispatch<SetStateAction<T>>; schema: FieldSchema; fieldProps?: TextFieldProps; /** Provides a method to override the simple MenuItem rendering of the select if required */ children?: ReactNode | undefined; } const SchemaField = <T,>( props: SchemaFieldProps<T> ): ReactElement<any, any> => { const { schema } = props; return ( <React.Fragment key={schema.fieldId}> {!schema.options?.length ? ( <SchemaTextField<T> {...props} /> ) : ( <SchemaSelectField<T> {...props} /> )} </React.Fragment> ); }; export default SchemaField; /* * Interior data type specific rendering. These should not be exported */ const SchemaTextField = <T,>({ value, setValue, schema, fieldProps = {}, }: SchemaFieldProps<T>): ReactElement<any, any> => { fieldProps.inputProps = fieldProps.inputProps || {}; switch (schema.type) { case "number": fieldProps.inputProps.inputMode = fieldProps.inputMode || "numeric"; break; } return ( <TextField label={schema.displayName} required={schema?.required || false} value={value} onChange={getTextFieldChangeHandler(setValue)} {...fieldProps} /> ); }; const SchemaSelectField = <T,>({ value, setValue, schema, fieldProps, children, }: SchemaFieldProps<T>): ReactElement<any, any> => { return ( <Select label={schema.displayName} required={schema?.required || false} value={value} onChange={getSelectChangeHandler(setValue)} {...fieldProps} > {children ? children : (schema.options || []).map((option) => ( <MenuItem key={option.value} value={option.value || undefined}> {option.displayName || option.value} </MenuItem> ))} </Select> ); }; /** Defines schema meta data about a field */ export type FieldSchema = { /** A preformatted display name safe to display in HTML context : AWS Region */ displayName: string; /** An id used to match up to the values : i.e. AWSRegion */ fieldId: string | number; /** Allows the selection of multiple options */ multiple?: Maybe<boolean>; /** A set of options to be used for limited choice options - Ex: [us-east-1, us-west] */ options?: Maybe<Array<FieldSchemaOption>>; /** Defines if this field is required */ required?: Maybe<boolean>; type: "number" | "string"; // TODO "coordinates" | "date" }; export type FieldSchemaOption = { /** A preformatted display name safe to display in HTML context : US East 1A */ displayName?: React.ReactNode; /** The value of this option: us-east-1a */ value: string | number | undefined | null; };

šŸŽ„ Merry Christmas šŸŽ„ ya'll.