about summary refs log tree commit diff
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-11-16 11:16:31 -0800
committerGitHub <noreply@github.com>2023-11-16 11:16:31 -0800
commite637798e05ba3bfc1c78be1b0f70e8b0ac22554d (patch)
tree61d60e597d406744125c4dcf71a6c63cebc3a47b
parent9f7a162a96200aaca0512765eff938a88c84d6d6 (diff)
downloadvoidsky-e637798e05ba3bfc1c78be1b0f70e8b0ac22554d.tar.zst
Refactor account-creation to use react-query and a reducer (react-query refactor) (#1931)
* Refactor account-creation to use react-query and a reducer

* Add translations

* Missing translate
-rw-r--r--src/lib/constants.ts8
-rw-r--r--src/state/models/ui/create-account.ts223
-rw-r--r--src/state/queries/service.ts20
-rw-r--r--src/view/com/auth/create/CreateAccount.tsx103
-rw-r--r--src/view/com/auth/create/Policies.tsx2
-rw-r--r--src/view/com/auth/create/Step1.tsx52
-rw-r--r--src/view/com/auth/create/Step2.tsx43
-rw-r--r--src/view/com/auth/create/Step3.tsx23
-rw-r--r--src/view/com/auth/create/state.ts242
-rw-r--r--src/view/com/modals/ChangeHandle.tsx4
10 files changed, 383 insertions, 337 deletions
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 89c441e98..f8f651305 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,4 +1,10 @@
-import {Insets} from 'react-native'
+import {Insets, Platform} from 'react-native'
+
+export const LOCAL_DEV_SERVICE =
+  Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
+export const STAGING_SERVICE = 'https://staging.bsky.dev'
+export const PROD_SERVICE = 'https://bsky.social'
+export const DEFAULT_SERVICE = PROD_SERVICE
 
 const HELP_DESK_LANG = 'en-us'
 export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
diff --git a/src/state/models/ui/create-account.ts b/src/state/models/ui/create-account.ts
deleted file mode 100644
index 60f4fc184..000000000
--- a/src/state/models/ui/create-account.ts
+++ /dev/null
@@ -1,223 +0,0 @@
-import {makeAutoObservable} from 'mobx'
-import {RootStoreModel} from '../root-store'
-import {ServiceDescription} from '../session'
-import {DEFAULT_SERVICE} from 'state/index'
-import {ComAtprotoServerCreateAccount} from '@atproto/api'
-import * as EmailValidator from 'email-validator'
-import {createFullHandle} from 'lib/strings/handles'
-import {cleanError} from 'lib/strings/errors'
-import {getAge} from 'lib/strings/time'
-import {track} from 'lib/analytics/analytics'
-import {logger} from '#/logger'
-import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
-import {ApiContext as SessionApiContext} from '#/state/session'
-
-const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
-
-export class CreateAccountModel {
-  step: number = 1
-  isProcessing = false
-  isFetchingServiceDescription = false
-  didServiceDescriptionFetchFail = false
-  error = ''
-
-  serviceUrl = DEFAULT_SERVICE
-  serviceDescription: ServiceDescription | undefined = undefined
-  userDomain = ''
-  inviteCode = ''
-  email = ''
-  password = ''
-  handle = ''
-  birthDate = DEFAULT_DATE
-
-  constructor(public rootStore: RootStoreModel) {
-    makeAutoObservable(this, {}, {autoBind: true})
-  }
-
-  get isAge13() {
-    return getAge(this.birthDate) >= 13
-  }
-
-  get isAge18() {
-    return getAge(this.birthDate) >= 18
-  }
-
-  // form state controls
-  // =
-
-  next() {
-    this.error = ''
-    if (this.step === 2) {
-      if (!this.isAge13) {
-        this.error =
-          'Unfortunately, you do not meet the requirements to create an account.'
-        return
-      }
-    }
-    this.step++
-  }
-
-  back() {
-    this.error = ''
-    this.step--
-  }
-
-  setStep(v: number) {
-    this.step = v
-  }
-
-  async fetchServiceDescription() {
-    this.setError('')
-    this.setIsFetchingServiceDescription(true)
-    this.setDidServiceDescriptionFetchFail(false)
-    this.setServiceDescription(undefined)
-    if (!this.serviceUrl) {
-      return
-    }
-    try {
-      const desc = await this.rootStore.session.describeService(this.serviceUrl)
-      this.setServiceDescription(desc)
-      this.setUserDomain(desc.availableUserDomains[0])
-    } catch (err: any) {
-      logger.warn(
-        `Failed to fetch service description for ${this.serviceUrl}`,
-        {error: err},
-      )
-      this.setError(
-        'Unable to contact your service. Please check your Internet connection.',
-      )
-      this.setDidServiceDescriptionFetchFail(true)
-    } finally {
-      this.setIsFetchingServiceDescription(false)
-    }
-  }
-
-  async submit({
-    createAccount,
-    onboardingDispatch,
-  }: {
-    createAccount: SessionApiContext['createAccount']
-    onboardingDispatch: OnboardingDispatchContext
-  }) {
-    if (!this.email) {
-      this.setStep(2)
-      return this.setError('Please enter your email.')
-    }
-    if (!EmailValidator.validate(this.email)) {
-      this.setStep(2)
-      return this.setError('Your email appears to be invalid.')
-    }
-    if (!this.password) {
-      this.setStep(2)
-      return this.setError('Please choose your password.')
-    }
-    if (!this.handle) {
-      this.setStep(3)
-      return this.setError('Please choose your handle.')
-    }
-    this.setError('')
-    this.setIsProcessing(true)
-
-    try {
-      onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
-      await createAccount({
-        service: this.serviceUrl,
-        email: this.email,
-        handle: createFullHandle(this.handle, this.userDomain),
-        password: this.password,
-        inviteCode: this.inviteCode.trim(),
-      })
-      track('Create Account')
-    } catch (e: any) {
-      onboardingDispatch({type: 'skip'}) // undo starting the onboard
-      let errMsg = e.toString()
-      if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
-        errMsg =
-          'Invite code not accepted. Check that you input it correctly and try again.'
-      }
-      logger.error('Failed to create account', {error: e})
-      this.setIsProcessing(false)
-      this.setError(cleanError(errMsg))
-      throw e
-    }
-  }
-
-  // form state accessors
-  // =
-
-  get canBack() {
-    return this.step > 1
-  }
-
-  get canNext() {
-    if (this.step === 1) {
-      return !!this.serviceDescription
-    } else if (this.step === 2) {
-      return (
-        (!this.isInviteCodeRequired || this.inviteCode) &&
-        !!this.email &&
-        !!this.password
-      )
-    }
-    return !!this.handle
-  }
-
-  get isServiceDescribed() {
-    return !!this.serviceDescription
-  }
-
-  get isInviteCodeRequired() {
-    return this.serviceDescription?.inviteCodeRequired
-  }
-
-  // setters
-  // =
-
-  setIsProcessing(v: boolean) {
-    this.isProcessing = v
-  }
-
-  setIsFetchingServiceDescription(v: boolean) {
-    this.isFetchingServiceDescription = v
-  }
-
-  setDidServiceDescriptionFetchFail(v: boolean) {
-    this.didServiceDescriptionFetchFail = v
-  }
-
-  setError(v: string) {
-    this.error = v
-  }
-
-  setServiceUrl(v: string) {
-    this.serviceUrl = v
-  }
-
-  setServiceDescription(v: ServiceDescription | undefined) {
-    this.serviceDescription = v
-  }
-
-  setUserDomain(v: string) {
-    this.userDomain = v
-  }
-
-  setInviteCode(v: string) {
-    this.inviteCode = v
-  }
-
-  setEmail(v: string) {
-    this.email = v
-  }
-
-  setPassword(v: string) {
-    this.password = v
-  }
-
-  setHandle(v: string) {
-    this.handle = v
-  }
-
-  setBirthDate(v: Date) {
-    this.birthDate = v
-  }
-}
diff --git a/src/state/queries/service.ts b/src/state/queries/service.ts
index df12d6cbc..5f7e10778 100644
--- a/src/state/queries/service.ts
+++ b/src/state/queries/service.ts
@@ -1,16 +1,26 @@
+import {BskyAgent} from '@atproto/api'
 import {useQuery} from '@tanstack/react-query'
 
-import {useSession} from '#/state/session'
-
 export const RQKEY = (serviceUrl: string) => ['service', serviceUrl]
 
-export function useServiceQuery() {
-  const {agent} = useSession()
+export function useServiceQuery(serviceUrl: string) {
   return useQuery({
-    queryKey: RQKEY(agent.service.toString()),
+    queryKey: RQKEY(serviceUrl),
     queryFn: async () => {
+      const agent = new BskyAgent({service: serviceUrl})
       const res = await agent.com.atproto.server.describeServer()
       return res.data
     },
+    enabled: isValidUrl(serviceUrl),
   })
 }
+
+function isValidUrl(url: string) {
+  try {
+    // eslint-disable-next-line @typescript-eslint/no-unused-vars
+    const urlp = new URL(url)
+    return true
+  } catch {
+    return false
+  }
+}
diff --git a/src/view/com/auth/create/CreateAccount.tsx b/src/view/com/auth/create/CreateAccount.tsx
index 0f3ff41af..ab6d34584 100644
--- a/src/view/com/auth/create/CreateAccount.tsx
+++ b/src/view/com/auth/create/CreateAccount.tsx
@@ -7,18 +7,17 @@ import {
   TouchableOpacity,
   View,
 } from 'react-native'
-import {observer} from 'mobx-react-lite'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {Text} from '../../util/text/Text'
 import {LoggedOutLayout} from 'view/com/util/layouts/LoggedOutLayout'
 import {s} from 'lib/styles'
-import {useStores} from 'state/index'
-import {CreateAccountModel} from 'state/models/ui/create-account'
 import {usePalette} from 'lib/hooks/usePalette'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useOnboardingDispatch} from '#/state/shell'
 import {useSessionApi} from '#/state/session'
+import {useCreateAccount, submit} from './state'
+import {useServiceQuery} from '#/state/queries/service'
 import {
   usePreferencesSetBirthDateMutation,
   useSetSaveFeedsMutation,
@@ -30,16 +29,11 @@ import {Step1} from './Step1'
 import {Step2} from './Step2'
 import {Step3} from './Step3'
 
-export const CreateAccount = observer(function CreateAccountImpl({
-  onPressBack,
-}: {
-  onPressBack: () => void
-}) {
+export function CreateAccount({onPressBack}: {onPressBack: () => void}) {
   const {track, screen} = useAnalytics()
   const pal = usePalette('default')
-  const store = useStores()
-  const model = React.useMemo(() => new CreateAccountModel(store), [store])
   const {_} = useLingui()
+  const [uiState, uiDispatch] = useCreateAccount()
   const onboardingDispatch = useOnboardingDispatch()
   const {createAccount} = useSessionApi()
   const {mutate: setBirthDate} = usePreferencesSetBirthDateMutation()
@@ -49,39 +43,59 @@ export const CreateAccount = observer(function CreateAccountImpl({
     screen('CreateAccount')
   }, [screen])
 
+  // fetch service info
+  // =
+
+  const {
+    data: serviceInfo,
+    isFetching: serviceInfoIsFetching,
+    error: serviceInfoError,
+    refetch: refetchServiceInfo,
+  } = useServiceQuery(uiState.serviceUrl)
+
   React.useEffect(() => {
-    model.fetchServiceDescription()
-  }, [model])
+    if (serviceInfo) {
+      uiDispatch({type: 'set-service-description', value: serviceInfo})
+      uiDispatch({type: 'set-error', value: ''})
+    } else if (serviceInfoError) {
+      uiDispatch({
+        type: 'set-error',
+        value: _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      })
+    }
+  }, [_, uiDispatch, serviceInfo, serviceInfoError])
 
-  const onPressRetryConnect = React.useCallback(
-    () => model.fetchServiceDescription(),
-    [model],
-  )
+  // event handlers
+  // =
 
   const onPressBackInner = React.useCallback(() => {
-    if (model.canBack) {
-      model.back()
+    if (uiState.canBack) {
+      uiDispatch({type: 'back'})
     } else {
       onPressBack()
     }
-  }, [model, onPressBack])
+  }, [uiState, uiDispatch, onPressBack])
 
   const onPressNext = React.useCallback(async () => {
-    if (!model.canNext) {
+    if (!uiState.canNext) {
       return
     }
-    if (model.step < 3) {
-      model.next()
+    if (uiState.step < 3) {
+      uiDispatch({type: 'next'})
     } else {
       try {
-        await model.submit({
+        await submit({
           onboardingDispatch,
           createAccount,
+          uiState,
+          uiDispatch,
+          _,
         })
-
-        setBirthDate({birthDate: model.birthDate})
-
-        if (IS_PROD(model.serviceUrl)) {
+        track('Create Account')
+        setBirthDate({birthDate: uiState.birthDate})
+        if (IS_PROD(uiState.serviceUrl)) {
           setSavedFeeds(DEFAULT_PROD_FEEDS)
         }
       } catch {
@@ -91,25 +105,36 @@ export const CreateAccount = observer(function CreateAccountImpl({
       }
     }
   }, [
-    model,
+    uiState,
+    uiDispatch,
     track,
     onboardingDispatch,
     createAccount,
     setBirthDate,
     setSavedFeeds,
+    _,
   ])
 
+  // rendering
+  // =
+
   return (
     <LoggedOutLayout
-      leadin={`Step ${model.step}`}
+      leadin={`Step ${uiState.step}`}
       title={_(msg`Create Account`)}
       description={_(msg`We're so excited to have you join us!`)}>
       <ScrollView testID="createAccount" style={pal.view}>
         <KeyboardAvoidingView behavior="padding">
           <View style={styles.stepContainer}>
-            {model.step === 1 && <Step1 model={model} />}
-            {model.step === 2 && <Step2 model={model} />}
-            {model.step === 3 && <Step3 model={model} />}
+            {uiState.step === 1 && (
+              <Step1 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
+            {uiState.step === 2 && (
+              <Step2 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
+            {uiState.step === 3 && (
+              <Step3 uiState={uiState} uiDispatch={uiDispatch} />
+            )}
           </View>
           <View style={[s.flexRow, s.pl20, s.pr20]}>
             <TouchableOpacity
@@ -121,12 +146,12 @@ export const CreateAccount = observer(function CreateAccountImpl({
               </Text>
             </TouchableOpacity>
             <View style={s.flex1} />
-            {model.canNext ? (
+            {uiState.canNext ? (
               <TouchableOpacity
                 testID="nextBtn"
                 onPress={onPressNext}
                 accessibilityRole="button">
-                {model.isProcessing ? (
+                {uiState.isProcessing ? (
                   <ActivityIndicator />
                 ) : (
                   <Text type="xl-bold" style={[pal.link, s.pr5]}>
@@ -134,19 +159,19 @@ export const CreateAccount = observer(function CreateAccountImpl({
                   </Text>
                 )}
               </TouchableOpacity>
-            ) : model.didServiceDescriptionFetchFail ? (
+            ) : serviceInfoError ? (
               <TouchableOpacity
                 testID="retryConnectBtn"
-                onPress={onPressRetryConnect}
+                onPress={() => refetchServiceInfo()}
                 accessibilityRole="button"
                 accessibilityLabel={_(msg`Retry`)}
-                accessibilityHint="Retries account creation"
+                accessibilityHint=""
                 accessibilityLiveRegion="polite">
                 <Text type="xl-bold" style={[pal.link, s.pr5]}>
                   <Trans>Retry</Trans>
                 </Text>
               </TouchableOpacity>
-            ) : model.isFetchingServiceDescription ? (
+            ) : serviceInfoIsFetching ? (
               <>
                 <ActivityIndicator color="#fff" />
                 <Text type="xl" style={[pal.text, s.pr5]}>
@@ -160,7 +185,7 @@ export const CreateAccount = observer(function CreateAccountImpl({
       </ScrollView>
     </LoggedOutLayout>
   )
-})
+}
 
 const styles = StyleSheet.create({
   stepContainer: {
diff --git a/src/view/com/auth/create/Policies.tsx b/src/view/com/auth/create/Policies.tsx
index 8eb669bcf..7d10f32fc 100644
--- a/src/view/com/auth/create/Policies.tsx
+++ b/src/view/com/auth/create/Policies.tsx
@@ -93,7 +93,7 @@ function validWebLink(url?: string): string | undefined {
 
 const styles = StyleSheet.create({
   policies: {
-    flexDirection: 'row',
+    flexDirection: 'column',
     gap: 8,
   },
   errorIcon: {
diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx
index 7e3ea062d..ab47b411f 100644
--- a/src/view/com/auth/create/Step1.tsx
+++ b/src/view/com/auth/create/Step1.tsx
@@ -1,10 +1,8 @@
 import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import debounce from 'lodash.debounce'
 import {Text} from 'view/com/util/text/Text'
 import {StepHeader} from './StepHeader'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch} from './state'
 import {useTheme} from 'lib/ThemeContext'
 import {usePalette} from 'lib/hooks/usePalette'
 import {s} from 'lib/styles'
@@ -22,10 +20,12 @@ import {LOGIN_INCLUDE_DEV_SERVERS} from 'lib/build-flags'
  * @field Bluesky (default)
  * @field Other (staging, local dev, your own PDS, etc.)
  */
-export const Step1 = observer(function Step1Impl({
-  model,
+export function Step1({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
   const [isDefaultSelected, setIsDefaultSelected] = React.useState(true)
@@ -33,35 +33,19 @@ export const Step1 = observer(function Step1Impl({
 
   const onPressDefault = React.useCallback(() => {
     setIsDefaultSelected(true)
-    model.setServiceUrl(PROD_SERVICE)
-    model.fetchServiceDescription()
-  }, [setIsDefaultSelected, model])
+    uiDispatch({type: 'set-service-url', value: PROD_SERVICE})
+  }, [setIsDefaultSelected, uiDispatch])
 
   const onPressOther = React.useCallback(() => {
     setIsDefaultSelected(false)
-    model.setServiceUrl('https://')
-    model.setServiceDescription(undefined)
-  }, [setIsDefaultSelected, model])
-
-  const fetchServiceDescription = React.useMemo(
-    () => debounce(() => model.fetchServiceDescription(), 1e3), // debouce for 1 second (1e3 = 1000ms)
-    [model],
-  )
+    uiDispatch({type: 'set-service-url', value: 'https://'})
+  }, [setIsDefaultSelected, uiDispatch])
 
   const onChangeServiceUrl = React.useCallback(
     (v: string) => {
-      model.setServiceUrl(v)
-      fetchServiceDescription()
-    },
-    [model, fetchServiceDescription],
-  )
-
-  const onDebugChangeServiceUrl = React.useCallback(
-    (v: string) => {
-      model.setServiceUrl(v)
-      model.fetchServiceDescription()
+      uiDispatch({type: 'set-service-url', value: v})
     },
-    [model],
+    [uiDispatch],
   )
 
   return (
@@ -90,7 +74,7 @@ export const Step1 = observer(function Step1Impl({
             testID="customServerInput"
             icon="globe"
             placeholder={_(msg`Hosting provider address`)}
-            value={model.serviceUrl}
+            value={uiState.serviceUrl}
             editable
             onChange={onChangeServiceUrl}
             accessibilityHint="Input hosting provider address"
@@ -104,26 +88,26 @@ export const Step1 = observer(function Step1Impl({
                 type="default"
                 style={s.mr5}
                 label={_(msg`Staging`)}
-                onPress={() => onDebugChangeServiceUrl(STAGING_SERVICE)}
+                onPress={() => onChangeServiceUrl(STAGING_SERVICE)}
               />
               <Button
                 testID="localDevServerBtn"
                 type="default"
                 label={_(msg`Dev Server`)}
-                onPress={() => onDebugChangeServiceUrl(LOCAL_DEV_SERVICE)}
+                onPress={() => onChangeServiceUrl(LOCAL_DEV_SERVICE)}
               />
             </View>
           )}
         </View>
       </Option>
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : (
         <HelpTip text={_(msg`You can change hosting providers at any time.`)} />
       )}
     </View>
   )
-})
+}
 
 function Option({
   children,
diff --git a/src/view/com/auth/create/Step2.tsx b/src/view/com/auth/create/Step2.tsx
index 3cc8ae934..89fd070ad 100644
--- a/src/view/com/auth/create/Step2.tsx
+++ b/src/view/com/auth/create/Step2.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch, is18} from './state'
 import {Text} from 'view/com/util/text/Text'
 import {DateInput} from 'view/com/util/forms/DateInput'
 import {StepHeader} from './StepHeader'
@@ -24,10 +23,12 @@ import {useModalControls} from '#/state/modals'
  * @field Birth date
  * @readonly Terms of service & privacy policy
  */
-export const Step2 = observer(function Step2Impl({
-  model,
+export function Step2({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
@@ -41,7 +42,7 @@ export const Step2 = observer(function Step2Impl({
     <View>
       <StepHeader step="2" title={_(msg`Your account`)} />
 
-      {model.isInviteCodeRequired && (
+      {uiState.isInviteCodeRequired && (
         <View style={s.pb20}>
           <Text type="md-medium" style={[pal.text, s.mb2]}>
             Invite code
@@ -50,16 +51,16 @@ export const Step2 = observer(function Step2Impl({
             testID="inviteCodeInput"
             icon="ticket"
             placeholder={_(msg`Required for this provider`)}
-            value={model.inviteCode}
+            value={uiState.inviteCode}
             editable
-            onChange={model.setInviteCode}
+            onChange={value => uiDispatch({type: 'set-invite-code', value})}
             accessibilityLabel={_(msg`Invite code`)}
             accessibilityHint="Input invite code to proceed"
           />
         </View>
       )}
 
-      {!model.inviteCode && model.isInviteCodeRequired ? (
+      {!uiState.inviteCode && uiState.isInviteCodeRequired ? (
         <Text style={[s.alignBaseline, pal.text]}>
           Don't have an invite code?{' '}
           <TouchableWithoutFeedback
@@ -83,9 +84,9 @@ export const Step2 = observer(function Step2Impl({
               testID="emailInput"
               icon="envelope"
               placeholder={_(msg`Enter your email address`)}
-              value={model.email}
+              value={uiState.email}
               editable
-              onChange={model.setEmail}
+              onChange={value => uiDispatch({type: 'set-email', value})}
               accessibilityLabel={_(msg`Email`)}
               accessibilityHint="Input email for Bluesky waitlist"
               accessibilityLabelledBy="email"
@@ -103,10 +104,10 @@ export const Step2 = observer(function Step2Impl({
               testID="passwordInput"
               icon="lock"
               placeholder={_(msg`Choose your password`)}
-              value={model.password}
+              value={uiState.password}
               editable
               secureTextEntry
-              onChange={model.setPassword}
+              onChange={value => uiDispatch({type: 'set-password', value})}
               accessibilityLabel={_(msg`Password`)}
               accessibilityHint="Set password"
               accessibilityLabelledBy="password"
@@ -122,8 +123,8 @@ export const Step2 = observer(function Step2Impl({
             </Text>
             <DateInput
               testID="birthdayInput"
-              value={model.birthDate}
-              onChange={model.setBirthDate}
+              value={uiState.birthDate}
+              onChange={value => uiDispatch({type: 'set-birth-date', value})}
               buttonType="default-light"
               buttonStyle={[pal.border, styles.dateInputButton]}
               buttonLabelType="lg"
@@ -133,20 +134,20 @@ export const Step2 = observer(function Step2Impl({
             />
           </View>
 
-          {model.serviceDescription && (
+          {uiState.serviceDescription && (
             <Policies
-              serviceDescription={model.serviceDescription}
-              needsGuardian={!model.isAge18}
+              serviceDescription={uiState.serviceDescription}
+              needsGuardian={!is18(uiState)}
             />
           )}
         </>
       )}
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   error: {
diff --git a/src/view/com/auth/create/Step3.tsx b/src/view/com/auth/create/Step3.tsx
index 09fba0714..3b628b6b6 100644
--- a/src/view/com/auth/create/Step3.tsx
+++ b/src/view/com/auth/create/Step3.tsx
@@ -1,7 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
-import {observer} from 'mobx-react-lite'
-import {CreateAccountModel} from 'state/models/ui/create-account'
+import {CreateAccountState, CreateAccountDispatch} from './state'
 import {Text} from 'view/com/util/text/Text'
 import {StepHeader} from './StepHeader'
 import {s} from 'lib/styles'
@@ -15,10 +14,12 @@ import {useLingui} from '@lingui/react'
 /** STEP 3: Your user handle
  * @field User handle
  */
-export const Step3 = observer(function Step3Impl({
-  model,
+export function Step3({
+  uiState,
+  uiDispatch,
 }: {
-  model: CreateAccountModel
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
 }) {
   const pal = usePalette('default')
   const {_} = useLingui()
@@ -30,9 +31,9 @@ export const Step3 = observer(function Step3Impl({
           testID="handleInput"
           icon="at"
           placeholder="e.g. alice"
-          value={model.handle}
+          value={uiState.handle}
           editable
-          onChange={model.setHandle}
+          onChange={value => uiDispatch({type: 'set-handle', value})}
           // TODO: Add explicit text label
           accessibilityLabel={_(msg`User handle`)}
           accessibilityHint="Input your user handle"
@@ -40,16 +41,16 @@ export const Step3 = observer(function Step3Impl({
         <Text type="lg" style={[pal.text, s.pl5, s.pt10]}>
           <Trans>Your full handle will be</Trans>
           <Text type="lg-bold" style={[pal.text, s.ml5]}>
-            @{createFullHandle(model.handle, model.userDomain)}
+            @{createFullHandle(uiState.handle, uiState.userDomain)}
           </Text>
         </Text>
       </View>
-      {model.error ? (
-        <ErrorMessage message={model.error} style={styles.error} />
+      {uiState.error ? (
+        <ErrorMessage message={uiState.error} style={styles.error} />
       ) : undefined}
     </View>
   )
-})
+}
 
 const styles = StyleSheet.create({
   error: {
diff --git a/src/view/com/auth/create/state.ts b/src/view/com/auth/create/state.ts
new file mode 100644
index 000000000..4df82f8fc
--- /dev/null
+++ b/src/view/com/auth/create/state.ts
@@ -0,0 +1,242 @@
+import {useReducer} from 'react'
+import {
+  ComAtprotoServerDescribeServer,
+  ComAtprotoServerCreateAccount,
+} from '@atproto/api'
+import {I18nContext, useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+import * as EmailValidator from 'email-validator'
+import {getAge} from 'lib/strings/time'
+import {logger} from '#/logger'
+import {createFullHandle} from '#/lib/strings/handles'
+import {cleanError} from '#/lib/strings/errors'
+import {DispatchContext as OnboardingDispatchContext} from '#/state/shell/onboarding'
+import {ApiContext as SessionApiContext} from '#/state/session'
+import {DEFAULT_SERVICE} from '#/lib/constants'
+
+export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
+
+export type CreateAccountAction =
+  | {type: 'set-step'; value: number}
+  | {type: 'set-error'; value: string | undefined}
+  | {type: 'set-processing'; value: boolean}
+  | {type: 'set-service-url'; value: string}
+  | {type: 'set-service-description'; value: ServiceDescription | undefined}
+  | {type: 'set-user-domain'; value: string}
+  | {type: 'set-invite-code'; value: string}
+  | {type: 'set-email'; value: string}
+  | {type: 'set-password'; value: string}
+  | {type: 'set-handle'; value: string}
+  | {type: 'set-birth-date'; value: Date}
+  | {type: 'next'}
+  | {type: 'back'}
+
+export interface CreateAccountState {
+  // state
+  step: number
+  error: string | undefined
+  isProcessing: boolean
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  userDomain: string
+  inviteCode: string
+  email: string
+  password: string
+  handle: string
+  birthDate: Date
+
+  // computed
+  canBack: boolean
+  canNext: boolean
+  isInviteCodeRequired: boolean
+}
+
+export type CreateAccountDispatch = (action: CreateAccountAction) => void
+
+export function useCreateAccount() {
+  const {_} = useLingui()
+  return useReducer(createReducer({_}), {
+    step: 1,
+    error: undefined,
+    isProcessing: false,
+    serviceUrl: DEFAULT_SERVICE,
+    serviceDescription: undefined,
+    userDomain: '',
+    inviteCode: '',
+    email: '',
+    password: '',
+    handle: '',
+    birthDate: DEFAULT_DATE,
+
+    canBack: false,
+    canNext: false,
+    isInviteCodeRequired: false,
+  })
+}
+
+export async function submit({
+  createAccount,
+  onboardingDispatch,
+  uiState,
+  uiDispatch,
+  _,
+}: {
+  createAccount: SessionApiContext['createAccount']
+  onboardingDispatch: OnboardingDispatchContext
+  uiState: CreateAccountState
+  uiDispatch: CreateAccountDispatch
+  _: I18nContext['_']
+}) {
+  if (!uiState.email) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please enter your email.`),
+    })
+  }
+  if (!EmailValidator.validate(uiState.email)) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Your email appears to be invalid.`),
+    })
+  }
+  if (!uiState.password) {
+    uiDispatch({type: 'set-step', value: 2})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please choose your password.`),
+    })
+  }
+  if (!uiState.handle) {
+    uiDispatch({type: 'set-step', value: 3})
+    return uiDispatch({
+      type: 'set-error',
+      value: _(msg`Please choose your handle.`),
+    })
+  }
+  uiDispatch({type: 'set-error', value: ''})
+  uiDispatch({type: 'set-processing', value: true})
+
+  try {
+    onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
+    await createAccount({
+      service: uiState.serviceUrl,
+      email: uiState.email,
+      handle: createFullHandle(uiState.handle, uiState.userDomain),
+      password: uiState.password,
+      inviteCode: uiState.inviteCode.trim(),
+    })
+  } catch (e: any) {
+    onboardingDispatch({type: 'skip'}) // undo starting the onboard
+    let errMsg = e.toString()
+    if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
+      errMsg = _(
+        msg`Invite code not accepted. Check that you input it correctly and try again.`,
+      )
+    }
+    logger.error('Failed to create account', {error: e})
+    uiDispatch({type: 'set-processing', value: false})
+    uiDispatch({type: 'set-error', value: cleanError(errMsg)})
+    throw e
+  }
+}
+
+export function is13(state: CreateAccountState) {
+  return getAge(state.birthDate) >= 18
+}
+
+export function is18(state: CreateAccountState) {
+  return getAge(state.birthDate) >= 18
+}
+
+function createReducer({_}: {_: I18nContext['_']}) {
+  return function reducer(
+    state: CreateAccountState,
+    action: CreateAccountAction,
+  ): CreateAccountState {
+    switch (action.type) {
+      case 'set-step': {
+        return compute({...state, step: action.value})
+      }
+      case 'set-error': {
+        return compute({...state, error: action.value})
+      }
+      case 'set-processing': {
+        return compute({...state, isProcessing: action.value})
+      }
+      case 'set-service-url': {
+        return compute({
+          ...state,
+          serviceUrl: action.value,
+          serviceDescription:
+            state.serviceUrl !== action.value
+              ? undefined
+              : state.serviceDescription,
+        })
+      }
+      case 'set-service-description': {
+        return compute({
+          ...state,
+          serviceDescription: action.value,
+          userDomain: action.value?.availableUserDomains[0] || '',
+        })
+      }
+      case 'set-user-domain': {
+        return compute({...state, userDomain: action.value})
+      }
+      case 'set-invite-code': {
+        return compute({...state, inviteCode: action.value})
+      }
+      case 'set-email': {
+        return compute({...state, email: action.value})
+      }
+      case 'set-password': {
+        return compute({...state, password: action.value})
+      }
+      case 'set-handle': {
+        return compute({...state, handle: action.value})
+      }
+      case 'set-birth-date': {
+        return compute({...state, birthDate: action.value})
+      }
+      case 'next': {
+        if (state.step === 2) {
+          if (!is13(state)) {
+            return compute({
+              ...state,
+              error: _(
+                msg`Unfortunately, you do not meet the requirements to create an account.`,
+              ),
+            })
+          }
+        }
+        return compute({...state, error: '', step: state.step + 1})
+      }
+      case 'back': {
+        return compute({...state, error: '', step: state.step - 1})
+      }
+    }
+  }
+}
+
+function compute(state: CreateAccountState): CreateAccountState {
+  let canNext = true
+  if (state.step === 1) {
+    canNext = !!state.serviceDescription
+  } else if (state.step === 2) {
+    canNext =
+      (!state.isInviteCodeRequired || !!state.inviteCode) &&
+      !!state.email &&
+      !!state.password
+  } else if (state.step === 3) {
+    canNext = !!state.handle
+  }
+  return {
+    ...state,
+    canBack: state.step > 1,
+    canNext,
+    isInviteCodeRequired: !!state.serviceDescription?.inviteCodeRequired,
+  }
+}
diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx
index 1a259b85e..da814b3d4 100644
--- a/src/view/com/modals/ChangeHandle.tsx
+++ b/src/view/com/modals/ChangeHandle.tsx
@@ -33,12 +33,12 @@ export const snapPoints = ['100%']
 export type Props = {onChanged: () => void}
 
 export function Component(props: Props) {
-  const {currentAccount} = useSession()
+  const {agent, currentAccount} = useSession()
   const {
     isLoading,
     data: serviceInfo,
     error: serviceInfoError,
-  } = useServiceQuery()
+  } = useServiceQuery(agent.service.toString())
 
   return isLoading || !currentAccount ? (
     <View style={{padding: 18}}>