DEV Community

tumit
tumit

Posted on

Angular -Typed Reactive Form

หลังจาก Angular v14 ออก feature ที่ผมสนใจเป็นพิเศษคือ Typed Reactive Form เรามาลองดูสิว่ามันเปลี่ยนวิธีเขียนไปมากน้อยแค่ไหน

ทำไมต้อง Type

ข้อดีของการมี type ในการเขียนโปรแกรมข้อนึงคือ เราสามารถเขียนกำหนด biz spec ลงไปใน code ได้เช่น data ก้อนนี้มี field อะไรบ้าง ประเภทเป็นตัวหนังสือหรือตัวเลข, เป็น mandatory หรือ optional ทำให้รู้ว่าเราเขียนโปรแกรมหลุดจาก biz logic ได้เลย ณ compile time หรือถ้าใช้ editor ฉลาดพอมันจะบ่นตั้งแต่เขียนเลย

export interface Stock {
  id: string;
  abbr: string;
  remark?: string;
}
Enter fullscreen mode Exit fullscreen mode

Reactive Form เดิม

แต่ก่อน Reactive form ของ Angular ไม่ได้กำหนด type มาก่อนทำให้วิธีเขียนจะค่อนข้าง dynamic มาก ๆ แต่ก็มีข้อเสียคือเวลามีการอัปเดต field เราจะไม่รู้ทันที ใน Angular 14 โดย default จะมี type แล้ว ดังนั้นถ้าใครยังอยากใช้แบบไม่มี type ต้องเปลี่ยนไปใช้ UntypedFormControl แทน หรือท่าที่สะดวกกว่านั้นคือใช้ ng update มันจะ convert พวก FormXXX ให้เป็น UntypedFormXXX ให้เองแบบอัตโนนาโถ

ng update @angular/core@14 @angular/cli@14

Image description

ว่าแล้วเรามาลองรีวิวดูว่า Typed Reactive Form มาอะไรให้เราใช้กันบ้าง

น้องเล็ก FormControl

เริ่มจาก FormControl เราถ้าเราไม่ได้ประกาศ type ให้ value ก็ยังเป็น any อยู่

const searchBox = new FormControl();
const value = searchBox.value;
console.log(value) // value: any    
Enter fullscreen mode Exit fullscreen mode

แต่ถ้าเราเพิ่มค่าเริ่มต้นให้ type string ของ value มันจะกลายเป็น string | null ทันที และไม่สามารถ setValue เป็น type อื่นได้นอกจาก string | null

const searchBoxStr = new FormControl('');
const valueString = searchBoxStr.value;
console.log(valueString) // valueStr: string | null
// searchBoxStr.setValue(1) // type validate
searchBox.reset() // then searchBox.value to be null
Enter fullscreen mode Exit fullscreen mode

ที่มันติดค่า null ไว้เพราะ FormControl มี method reset() ซึ่งโดย default แล้วมันจะ setValue กลับไป null

ถ้า biz spec บอกว่า ค่านี้เป็น null ไม่ได้เราสามารถเพิ่ม option ให้ FormControl ไม่เอาค่า null ได้ด้วย { nonNullable: true } พอ method reset() ทำงานมันจะเอาค่าที่ init ไปใส่แทน

const searchBoxNotNull = new FormControl('', { nonNullable: true })
const valueNotNull = searchBoxNotNull.value;
console.log(valueNotNull) // valueNotNull: string
Enter fullscreen mode Exit fullscreen mode

แต่สุดท้ายถ้าเราอยากกำหนด type แบบจริงจังว่าเป็นอะไรก็ทำได้เช่นกัน ด้วยการใส่ FormControl แบบตรง ๆ

const searchTyped = new FormControl<string|null>('', { nonNullable: true })
const valueTyped = searchTyped.value;
console.log(valueTyped) // valueTyped: string | null
searchTyped.setValue(null);
searchTyped.setValue('Abc');
searchTyped.reset(); // reset to init value
Enter fullscreen mode Exit fullscreen mode

พี่ใหญ่ FormGroup

คราวนี้ถ้าเรา เอา FormControl ไปใส่ใน FormGroup จะเกิดอะไรขึ้นบ้าง

const stockForm = new FormGroup({
  id: new FormControl('', { nonNullable: true }),
  abbr: new FormControl('', { nonNullable: true }),
  address: new FormGroup({
    province: new FormControl('', { nonNullable: true }),
    zipCode: new FormControl('', { nonNullable: true })
  })
});

const stockID = stockForm.value.id; // string | undefine
const stockAbbr = stockForm.value.abbr; // string | undefine
const stockAddress = stockForm.value.address; // Partial<{ province, zipCode}> | undefine
Enter fullscreen mode Exit fullscreen mode

ถ้าเราสังเกตจะเห็นว่าเวลาเราเอา stockID หรือ stockAbbr มันจะได้ค่าเป็น string | undefine เพราะเวลาเรา disable FormControl ที่อยู่ใน FormGroup มันจะทำให้ค่าเป็น undefine ในทางเดียวกัน ถ้าเป็น Nested FormGroup ที่ซ้อนลงไปค่าที่ได้ก็จะเป็น Partial เพราะมันมีโอกาสที่จะเป็น undefine และได้ field มาไม่ครบได้เหมือนกัน

ถ้า spec บอกว่าอยากได้ field ครบเสมอ เราใช้ getRawValue() แทน

const stockIDRaw = stockForm.getRawValue().id; // string
const stockAbbrRaw = stockForm.getRawValue().abbr; // string
const stockAddressRaw = stockForm.getRawValue().address; // { province, zipCode }
Enter fullscreen mode Exit fullscreen mode

แต่ถ้าเราพยายามตีมึนใส่ของที่ไม่ได้ spec ไว้มันจะด่าทันที ว่า name ไม่มี
Image description

Image description

กรณีที่เราระบุ spec ชัดเจนแบบนี่ทำให้ editot & compiler ช่วยเช็คได้ว่า field ที่เป็น mandaroty ต้อง remove ไม่ได้ แต่ optional ทำได้แบบนี้

เพื่อนสนิท FormBuilder

พอมี type เพิ่มเข้ามา FormBuilder เลยมี .nonNullable เพื่อช่วยสร้าง FormGroup แบบ nonNullable ได้แบบนี้

@Component({})
export class StockComponent {

  protected stockFormFb = this.fb.nonNullable.group({
    id: '',
    abbr: ''
  });

  constructor(private fb: FormBuilder) {
    this.stockFormFb.value; // Partail<{id, abbr}>
    this.stockFormFb.getRawValue(); {id, abbr}
  }
Enter fullscreen mode Exit fullscreen mode

นอกจากนี้ FormBuilder ยังมีท่าที่น่าสนใจอีกท่านึงคือเราสามารถส่ง interface ที่เป็น model spec ลงไปได้ ทำให้การล็อก spec การสร้าง FormGroup ได้อีกด้วย

@Component({})
export class StockComponent {

  protected stockFormFbInterface = this.fb.nonNullable.group<Stock>({
    id: '',
    abbr: '',
  });

  constructor(private fb: FormBuilder) {
    this.stockFormFbInterface.value; // Partail {id, abbr, remark?}
    this.stockFormFbInterface.getRawValue(); // {id, abbr, remark?}
  }
}
Enter fullscreen mode Exit fullscreen mode

ถ้ามีของแปลกปลอมจะแดงทันที
Image description

แต่ถ้า spec บอกว่ายังไงก็ nonNullable เราสามารถ inject NonNullableFormBuilder เพื่อไม่ต้องคอย .nonNullable ได้ประมาณนี้

@Component({
  selector: 'app-stock',
  template: ``
})
export class StockComponent {

  protected stockFormNnInterface = this.nnFb.group<Stock>({
    id: '',
    abbr: '',
  })

  constructor(private nnFb: NonNullableFormBuilder) {
    this.stockFormNnInterface.value;
  }
}
Enter fullscreen mode Exit fullscreen mode

น้องใหม่ FormRecord

บางครั้งเราอยากจะเจอโจทย์ประมาณอยากให้ Form ของเรามี filed dynamic ณ runtime ยังนึกเคสไม่ออกแต่คิดว่าคงประมาณนี้

// runtime data
const stocks: Stock[] = [
  { id: 'BKK.ABC', abbr: 'ABC' },
  { id: 'CGM.ABC', abbr: 'ABC' },
  { id: 'BKK.DGF', abbr: 'DEF' }
];
// runtime init
const topStockRecord = new FormRecord<FormControl<string>>({});
stocks.forEach(stock =>
  topStockRecord.addControl(
    stock.id,
    new FormControl(stock.abbr, { nonNullable: true })
  )
);
Enter fullscreen mode Exit fullscreen mode

ของแถม

จาก blog @netbasal เขาแถม type ที่ชื่อว่า ControlsOf (มาจาก lib ที่ชื่อ ngneat/reactive-forms) เพื่อให้สามารถกำหนด interface ให้เราจะได้เลยตอน new FormGroup ท่าก็จะประมาณนี้

export type ControlsOf<T extends Record<string, any>> = {
  [K in keyof T]: T[K] extends Record<any, any>
  ? FormGroup<ControlsOf<T[K]>>
  : FormControl<T[K]>;
};

@Component({
  selector: 'app-stock',
  template: ``
})
export class StockComponent {
  protected stockControlOf = new FormGroup<ControlsOf<Stock>>({
    id: new FormControl('', {nonNullable: true}),
    abbr: new FormControl()
  })

  constructor() {
    this.stockControlOf.value;
    this.stockControlOf.getRawValue();
  }
}
Enter fullscreen mode Exit fullscreen mode

บ่นทันทีที่ field ไม่มี
Image description

เท่าที่ลองก็จะประมาณนี้ครับ ใครลองแล้วมีอะไรมากกว่านี้ก็แนะนำกันได้นะครับ สวัสดี (-/-)

Refs:

Top comments (0)