jaydenseric / graphql-react

A GraphQL client for React using modern context and hooks APIs that is lightweight (< 4 kB) but powerful; the first Relay and Apollo alternative with server side rendering.
https://npm.im/graphql-react
MIT License
718 stars 22 forks source link

Anyone used useGraphQL Mutation in Forms (Formik) ? #31

Closed resdevd closed 5 years ago

resdevd commented 5 years ago

My bad if the questions comes off as ignorant. Coming from data engineering background, I'm still a beginner in web development.

I've using UseGraphQL hook to fetch the data successfully and able to display the data on a nextjs ssr website. However when creating a form page with Formik library, I'm stuck at the error message "Hooks can only be called inside the body of a function component." Cannot find any similar examples using form to mutate data using GraphQL hook anywhere.

_app.js File:

import 'cross-fetch/polyfill'
import {GraphQLContext} from 'graphql-react'
import {withGraphQLApp} from 'next-graphql-react'
import App, {Container} from 'next/app'
import Head from 'next/head'

class KSApp extends App {
    render() {
        const {Component, pageProps, graphql} = this.props
        return (
            <Container>
                <Head>
                    <link rel="icon" href="/static/favicon.ico"/>
                </Head>
                <GraphQLContext.Provider value={graphql}>
                        <Component {...pageProps} />
                </GraphQLContext.Provider>

            </Container>
        )
    }
}

export default withGraphQLApp(KSApp)

FormPage File:

import React, {useState} from 'react'
import {Formik} from 'formik'
import {useGraphQL} from 'graphql-react'
import {KSStrapiFetchOptionsOverride} from './data/graphql-fetch-options'

const MutateContact = (values) => {
    useGraphQL({
        fetchOptionsOverride: KSStrapiFetchOptionsOverride,
        operation: {
            query: `
mutation {
  createContact (input: {
    data: {
      feedback: ${values.feedback}
    }
  }
  ) {
  contact {
    feedback
  }
  }
}
`
        }
    })
}

const BasicExample = () => {

    return <div>
        <h1>My Form</h1>
        <Formik
            initialValues={{feedback: 'some feedback'}}
            onSubmit={(values, actions) => {
                setTimeout(() => {
                    alert(JSON.stringify(values, null, 2))

                    MutateContact(values)

                    actions.setSubmitting(false)
                }, 1000)
            }}
            render={props => (
                <form onSubmit={props.handleSubmit}>
                    <input
                        type="text"
                        onChange={props.handleChange}
                        onBlur={props.handleBlur}
                        value={props.values.feedback}
                        name="feedback"
                    />
                    {props.errors.feedback && <div id="feedback">{props.errors.feedback}</div>}
                    <button type="submit">Submit</button>
                </form>
            )}
        />
    </div>
}

export default BasicExample

This results to "Hooks can only be called inside the body of a function component. (https://fb.me/react-invalid-hook-call)" error.

You might have mismatching versions of React and React DOM. (Ruled out) You might be breaking the Rules of Hooks. (Assuming this is causing error) You might have more than one copy of React in the same app. (Ruled out)

Can anyone point me towards right direction? Under assumption MutateContact is a function in the above code, so usage of hooks should be okay right?

jaydenseric commented 5 years ago

useGraphQL() is a React hook; it's not meant to be used in a callback like that. You pull out the load function at the top of your component's render so it can be used on demand later:

const Foo = () => {
  const { load } = useGraphQL(/* … */)

  // Somewhere in a callback function call load()…
}

Alternatively, you can pull in the GraphQL instance from context and then directly use the operate() method on demand.

I don't use Formik, it's too bloated in relation to bundle size and as of a few weeks ago when I was looking at it, it's not using React hooks. If/when it does use hooks they will likely be in addition to the legacy APIs.

It's good to make some lightweight hooks yourself. For inspiration, here is what my forms look like:

import PropTypes from 'prop-types'
import { useField } from '../hooks/useField'
import { useWhimsyForm } from '../hooks/useWhimsyForm'
import { Fieldset } from './Fieldset'
import { Form } from './Form'
import { InputText } from './InputText'

export const UserEditName = ({ name, nameLengthMax }) => {
  const {
    value: valueName,
    errors: errorsName,
    addError: addErrorName,
    onChangeInput: onChangeInputName
  } = useField({ initialValue: name })

  const operationName = 'userEditName'
  const { formRef, message, loading, success, onSubmit } = useWhimsyForm({
    operationName,
    operation: {
      query: /* GraphQL */ `
        mutation {
          ${operationName}(name: "${valueName}") {
            success
            errors {
              message
              field
            }
          }
        }
      `
    },
    failMessage: 'Name edit failed',
    onFieldError({ message, field }) {
      switch (field[0]) {
        case 'name':
          addErrorName(message)
      }
    }
  })

  return (
    <Form
      loading={loading}
      success={success}
      message={message}
      submitText="Save"
      onSubmit={onSubmit}
      formRef={formRef}
    >
      <Fieldset legend="Full name">
        <InputText
          placeholder="Full name"
          autoComplete="name"
          required
          maxLength={nameLengthMax}
          value={valueName}
          validationMessage={errorsName.join(' ')}
          onChange={onChangeInputName}
        />
      </Fieldset>
    </Form>
  )
}

UserEditName.propTypes = {
  name: PropTypes.string.isRequired,
  nameLengthMax: PropTypes.number.isRequired
}
import React from 'react'

export const useField = ({ initialValue, initialErrors = [] }) => {
  const [value, setStateValue] = React.useState(initialValue)
  const [errors, setErrors] = React.useState(initialErrors)

  const setValue = value => {
    setStateValue(value)
    setErrors([])
  }

  const addError = message => setErrors(errors => [...errors, message])

  const onChangeInput = ({ target: { value } }) => setValue(value)

  return { value, setValue, errors, setErrors, addError, onChangeInput }
}
import { GraphQLContext } from 'graphql-react'
import React from 'react'
import { useWhimsyAPI } from './useWhimsyAPI'

export const useWhimsyForm = ({
  operationName,
  operation,
  reloadOnLoad = true,
  failMessage,
  onFieldError,
  onSuccess
}) => {
  const graphql = React.useContext(GraphQLContext)
  const formRef = React.useRef(null)
  const [attempts, setAttempts] = React.useState(0)
  const [success, setSuccess] = React.useState(null)
  const [message, setMessage] = React.useState(null)

  const { load, loading, cacheKey } = useWhimsyAPI({
    loadOnMount: false,
    loadOnReload: false,
    loadOnReset: false,
    reloadOnLoad,
    operation
  })

  const onGraphQLCache = React.useCallback(
    ({
      cacheKey: cachedCacheKey,
      cacheValue: { data: { [operationName]: payload = {} } = {} } = {}
    }) => {
      if (cacheKey === cachedCacheKey) {
        setSuccess(payload.success)

        if (payload.errors) {
          const formErrors = []
          payload.errors.forEach(error => {
            if (error.field) {
              if (onFieldError) onFieldError(error)
            } else formErrors.push(error.message)
          })

          setMessage(
            `${failMessage}${
              formErrors.length ? `: ${formErrors.join(' ')}` : '.'
            }`
          )
        } else if (payload.success) {
          if (onSuccess) onSuccess(payload)
        } else setMessage(`${failMessage}.`)
      }
    },
    [cacheKey, failMessage, onFieldError, onSuccess, operationName]
  )

  const onSubmit = () => {
    setSuccess(null)
    setMessage(null)
    setAttempts(attempts => attempts + 1)
    load()
  }

  React.useEffect(() => {
    graphql.on('cache', onGraphQLCache)
    return () => graphql.off('cache', onGraphQLCache)
  }, [graphql, onGraphQLCache])

  React.useEffect(() => {
    if (attempts) {
      const timeout = setTimeout(() => {
        if (formRef.current) formRef.current.reportValidity()
      }, 200)

      return () => clearTimeout(timeout)
    }
  }, [formRef, attempts])

  return { formRef, loading, success, message, setMessage, onSubmit }
}
import { useGraphQL } from 'graphql-react'
import React from 'react'
import { whimsyFetchOptionsOverride } from '../api'
import { CookieContext } from '../components/CookieContext'

export const useWhimsyAPI = options => {
  const getCookie = React.useContext(CookieContext)
  return useGraphQL({
    fetchOptionsOverride: whimsyFetchOptionsOverride(getCookie),
    ...options
  })
}

You could still use Formik, but you would have to use the useGraphQL() hook properly and work it out yourself 🤓

jaydenseric commented 5 years ago

I just tweaked the above example to fix a few bugs, you can see the diff via the edits menu.

olistic commented 5 years ago

@jaydenseric Would the ability to pass new options when calling load() be something you could see being part of your library? Using Formik or not, I think it's a pretty common case not to know the values of the variables your mutation requires when calling useGraphQL() in the body of your functional component. Being able to provide those in a callback would be a great.

Suggestion

function load

Loads the GraphQL operation on demand, updating the GraphQL cache.

Parameter Type Description
options Object Options.
options.fetchOptionsOverride GraphQLFetchOptionsOverride? Overrides default fetch options for the GraphQL operation.
options.operation GraphQLOperation GraphQL operation.

Usage example

export const AuthProvider = ({ children }) => {
  const { load } = useGraphQL({
    /* ... */
    operation: {
      query: /* GraphQL */ `
        mutation($input: SignInInput!) {
          signIn(input: $input) {
            token
          }
        }
      `,
      // Note we don't provide variables at this time as they are not known.
    },
  });

  const signIn = async (username, password) => {
    await load({
      operation: {
        variables: {
          input: {
            username,
            password,
          },
        },
      },
    });

    /* ... */
  };

  return (
    <AuthContext.Provider value={{ signIn }}>{children}</AuthContext.Provider>
  );
};
jaydenseric commented 5 years ago

@olistic the useGraphQL hook needs the variables and everything to be fed into it for it to be able to tell the loading status and cache of that exact query/mutation. This is pretty straightforward if you track the input state via hooks before the useGraphQL hook, in the same component.

As mentioned earlier, you can pull in the GraphQL instance from context and then directly use the operate() method on demand.